index.ts 9.6 KB

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