index.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. /**
  2. * Opt-in request-preparation tmux-location context. Eligible step attempts
  3. * append durable, source-attributed context naming the tmux session, window,
  4. * and pane this agent process runs in, plus the window's pane-tree layout.
  5. *
  6. * The plugin pulls state once per turn, for the first request (`step === 1`), by
  7. * running one `tmux display-message` through the `ctx.shell` executor service. It
  8. * confirms this process genuinely runs inside the pane `$TMUX_PANE` names by
  9. * matching the pane's `#{pane_tty}` against this process's controlling terminal,
  10. * so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor
  11. * (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects
  12. * only when the rendered tmux state changes since the last injection (a moved,
  13. * renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor
  14. * between injections. Absent tmux environment, an inherited-only environment,
  15. * absent `ctx.shell`, or a failed query is a no-op, never an error: an executor
  16. * rejection is contained and logged as a warning so the turn continues.
  17. *
  18. * @module @deepseek-ai/dsh-tmux-context
  19. */
  20. import type { Context, LoggerService } from '@deepseek-ai/cordis'
  21. import z from '@deepseek-ai/schemastery'
  22. import { z as zod } from 'zod'
  23. import type { PreStepDecision } from '@deepseek-ai/dsh-agent'
  24. import type {} from '@deepseek-ai/dsh-session-projection'
  25. import type { ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell'
  26. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  27. /** Cordis plugin name used by loader diagnostics. */
  28. export const name = 'tmux-context'
  29. /** The agent registry that owns pre-step processing. */
  30. export const inject = ['agents', 'sessionProjections']
  31. /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */
  32. export interface Config {
  33. /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */
  34. refreshIntervalMs?: number
  35. }
  36. /** Schemastery validation for {@link Config}. */
  37. export const Config: z<Config> = z.object({
  38. refreshIntervalMs: z.number(),
  39. })
  40. /**
  41. * Tab-separated tmux format fields, in query order. Layout (`window_layout`)
  42. * is the pane-tree description; pane/window pixel sizes are intentionally
  43. * excluded (own location and layout only, per the package scope).
  44. */
  45. const TMUX_FIELDS = [
  46. '#{session_name}',
  47. '#{window_index}',
  48. '#{window_name}',
  49. '#{pane_index}',
  50. '#{pane_id}',
  51. '#{window_active}',
  52. '#{pane_active}',
  53. '#{window_layout}',
  54. ] as const
  55. /** Structured tmux location parsed from one `display-message` reading. */
  56. interface TmuxLocation {
  57. sessionName: string
  58. windowIndex: string
  59. windowName: string
  60. paneIndex: string
  61. paneId: string
  62. windowActive: string
  63. paneActive: string
  64. windowLayout: string
  65. }
  66. /** Prefix marking the volatile turn/step preamble line of a rendered reading. */
  67. const READING_PREFIX = 'tmux location (turn '
  68. /**
  69. * Field separator between tmux format fields. tmux does not interpret C escapes
  70. * in a format, so the literal two-character sequence `\t` is emitted verbatim
  71. * and split back out here; this avoids embedding raw whitespace in the command.
  72. */
  73. const FIELD_SEP = '\\t'
  74. /**
  75. * Read this process's tmux location through the bash seam, or `undefined` when
  76. * this process is not genuinely running inside a tmux pane or the query fails.
  77. *
  78. * `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell
  79. * (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and
  80. * `$TMUX_PANE` from that ancestor, so the variables are present even though this
  81. * process does not live in that pane. The command therefore also compares the
  82. * pane's `#{pane_tty}` against this process's own controlling terminal
  83. * (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty,
  84. * an inherited environment names some other pane's tty. Fields are emitted only
  85. * on a match, so an inherited environment reads as "not in tmux" and injects
  86. * nothing.
  87. *
  88. * The location is optional context, so an executor rejection is a failed query,
  89. * not a turn failure: `resolve()` may reject the command on policy grounds and
  90. * `run()` only promises to resolve for nonzero exits, timeouts, and aborts, so
  91. * both are contained and reported as a warning.
  92. *
  93. * @param bash - The executor service used to run the read-only tmux/ps commands.
  94. * @param logger - receives a warning when the executor rejects the query.
  95. * @param processId - this agent process's pid, whose controlling tty must match the pane.
  96. * @param signal - abort signal forwarded to the executor.
  97. * @returns the parsed location, or `undefined` when not in a real pane or on any failure.
  98. */
  99. async function queryTmuxLocation(
  100. bash: ShellExecutor,
  101. logger: LoggerService,
  102. processId: number,
  103. signal: AbortSignal,
  104. ): Promise<TmuxLocation | undefined> {
  105. const format = TMUX_FIELDS.join(FIELD_SEP)
  106. const command = [
  107. '[ -n "$TMUX_PANE" ] || exit 1',
  108. `self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`,
  109. '[ -n "$self_tty" ] || exit 1',
  110. 'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1',
  111. '[ "$pane_tty" = "/dev/$self_tty" ] || exit 1',
  112. `exec tmux display-message -t "$TMUX_PANE" -p '${format}'`,
  113. ].join('\n')
  114. let result: ShellRunResult
  115. try {
  116. result = await bash.run(bash.resolve({ command, signal }))
  117. } catch (error: unknown) {
  118. const message = error instanceof Error ? error.message : String(error)
  119. logger.warn(`tmux location query failed: ${message}; injecting no location this turn`)
  120. return undefined
  121. }
  122. if (result.exitCode !== 0) return undefined
  123. const line = result.stdout.text.split('\n', 1)[0] as string
  124. const parts = line.split(FIELD_SEP)
  125. if (parts.length !== TMUX_FIELDS.length) return undefined
  126. const [
  127. sessionName,
  128. windowIndex,
  129. windowName,
  130. paneIndex,
  131. paneId,
  132. windowActive,
  133. paneActive,
  134. windowLayout,
  135. ] = parts as [string, string, string, string, string, string, string, string]
  136. if (paneId.length === 0) return undefined
  137. return {
  138. sessionName,
  139. windowIndex,
  140. windowName,
  141. paneIndex,
  142. paneId,
  143. windowActive,
  144. paneActive,
  145. windowLayout,
  146. }
  147. }
  148. /**
  149. * Render the stable tmux state block: the part of a reading compared for
  150. * change suppression. It excludes the turn preamble so re-injection is driven
  151. * only by tmux state, not by loop position.
  152. */
  153. function renderState(location: TmuxLocation): string {
  154. return `session ${location.sessionName}, `
  155. + `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, `
  156. + `pane ${location.paneIndex} ${location.paneId}\n`
  157. + `window active=${location.windowActive}, pane active=${location.paneActive}, `
  158. + `layout ${location.windowLayout}`
  159. }
  160. /** Render the full durable reading, including the volatile turn preamble. */
  161. function renderReading(location: TmuxLocation, turn: number): string {
  162. return `${READING_PREFIX}${turn}):\n${renderState(location)}`
  163. }
  164. /**
  165. * The stable state block of this plugin's latest durable injection, or
  166. * `undefined` when the session has none. Scans raw durable events so the
  167. * schedule survives compaction and resumed processes without process-local
  168. * cache state.
  169. */
  170. /** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
  171. function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
  172. if (refreshIntervalMs !== undefined && (
  173. !Number.isSafeInteger(refreshIntervalMs)
  174. || refreshIntervalMs < 0
  175. )) {
  176. throw new TypeError(
  177. `tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
  178. )
  179. }
  180. }
  181. const tmuxContextStateSchema = zod.object({
  182. state: zod.string(),
  183. time: zod.number(),
  184. }).nullable()
  185. type TmuxContextState = zod.infer<typeof tmuxContextStateSchema>
  186. /**
  187. * Register a prepended pre-step listener for the lifetime of `ctx`.
  188. * @param ctx - plugin context; the listener is disposed with it.
  189. * @param config - durable refresh scheduling configuration.
  190. * @throws when the refresh interval is invalid.
  191. */
  192. declare module '@deepseek-ai/dsh-session-projection/types' {
  193. interface SessionProjectionStateMap {
  194. /** The stable state block of this plugin's latest durable injection, or null. */
  195. tmuxContext: TmuxContextState
  196. }
  197. }
  198. export function apply(ctx: Context, config: Config): void {
  199. const refreshIntervalMs = config.refreshIntervalMs
  200. validateRefreshInterval(refreshIntervalMs)
  201. ctx.sessionProjections.register({
  202. key: 'tmuxContext',
  203. stateVersion: 1,
  204. stateSchema: tmuxContextStateSchema,
  205. init: () => null,
  206. apply: (state, event) => {
  207. if (event.type !== 'user/message'
  208. || event.data.source.kind !== 'plugin'
  209. || event.data.source.plugin !== name) return state
  210. const [block] = event.data.content
  211. if (block?.type !== 'text') return state
  212. const newline = block.text.indexOf('\n')
  213. const stableState = newline === -1 ? '' : block.text.slice(newline + 1)
  214. return { state: stableState, time: event.time }
  215. },
  216. })
  217. ctx.on('agent/pre-step', async (
  218. { agent, turn, step, signal },
  219. next,
  220. ): Promise<PreStepDecision> => {
  221. const decision = await next()
  222. if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision
  223. const bash = ctx.get('shell')
  224. if (bash === undefined) return decision
  225. const previous = ctx.sessionProjections.stateOf(agent.session, 'tmuxContext') as TmuxContextState
  226. if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== null) {
  227. const now = Date.now()
  228. if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision
  229. }
  230. const location = await queryTmuxLocation(bash, ctx.logger, process.pid, signal)
  231. if (location === undefined) return decision
  232. const state = renderState(location)
  233. if (previous !== null && previous.state === state) return decision
  234. const text = renderReading(location, turn)
  235. return {
  236. ...decision,
  237. messages: [
  238. createUserMessage({
  239. content: [{ type: 'text', text }],
  240. source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
  241. }),
  242. ...decision.messages,
  243. ],
  244. }
  245. }, { prepend: true })
  246. }