@deepseek-ai/dsh-tmux-context 0.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, DeepSeek
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md
5
+ README.md: 59c452be53b8bb7cb7244a9bb2017749750ce4bf
6
+ README.zh.md: f343c9b25c832cafcb3366209bc2ce39308ad0d5
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @deepseek-ai/dsh-tmux-context
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. It is sampled once per turn during model-request preparation and is not part of the shipped Web/headless composition. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md).
6
+
7
+ ## Config
8
+
9
+ ```yaml
10
+ - id: tmux-context
11
+ name: '@deepseek-ai/dsh-tmux-context'
12
+ config:
13
+ refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn
14
+ ```
15
+
16
+ `refreshIntervalMs` must be a non-negative safe integer. Omission or `0` injects whenever the tmux state changed since the last injection. A positive value additionally suppresses injections that fall within that many milliseconds of the latest one.
17
+
18
+ ## How it reads tmux
19
+
20
+ The plugin prepends an `agent/pre-step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor service:
21
+
22
+ ```sh
23
+ [ -n "$TMUX_PANE" ] || exit 1
24
+ self_tty=$(ps -o tty= -p <pid> | tr -d ' ')
25
+ pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
26
+ [ "$pane_tty" = "/dev/$self_tty" ] || exit 1
27
+ exec tmux display-message -t "$TMUX_PANE" -p '<format>'
28
+ ```
29
+
30
+ `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error. The location is optional, so an executor rejection — a policy refusal from `resolve()` or an infrastructure failure from `run()` — is contained and logged as a warning rather than failing the turn.
31
+
32
+ State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing.
33
+
34
+ ## Timing semantics
35
+
36
+ The plugin prepends an `agent/pre-step` listener. When an injection is due and the downstream decision enters the proposed step, it prepends one sourced `UserMessage` to the returned batch. AgentLoop records that context after `step/start` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. A downstream pre-step listener that rejects or fails prevents the reading from being recorded.
37
+
38
+ ## Model Experience
39
+
40
+ ### Preparation-time tmux location
41
+
42
+ #### What the model sees
43
+
44
+ On each turn whose tmux state changed, one source-tagged context message with the three lines below. `<window-layout>` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured.
45
+
46
+ ##### Changed-turn reading
47
+
48
+ ```markdown
49
+ tmux location (turn <turn>):
50
+ session <session>, window <index> "<name>", pane <index> <pane-id>
51
+ window active=<0|1>, pane active=<0|1>, layout <window-layout>
52
+ ```
53
+
54
+ #### Token effect
55
+
56
+ Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing.
57
+
58
+ #### KV Cache effect
59
+
60
+ Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
61
+
62
+ ## Known Limitations and Deferred Work
63
+
64
+ - **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps.
65
+ - **Own location only** — the plugin never captures the visible text of sibling panes.
66
+ - **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported.
67
+ - **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected.
68
+ - **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable.
package/README.zh.md ADDED
@@ -0,0 +1,68 @@
1
+ # @deepseek-ai/dsh-tmux-context
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 可选启用的持久上下文,记录本 agent(智能体)进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次;随附 Web/无头组合不包含它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。
6
+
7
+ ## 配置
8
+
9
+ ```yaml
10
+ - id: tmux-context
11
+ name: '@deepseek-ai/dsh-tmux-context'
12
+ config:
13
+ refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn
14
+ ```
15
+
16
+ `refreshIntervalMs` 必须是非负安全整数。省略或 `0` 表示只要 tmux 状态自上次注入以来发生变化就注入。正值会额外抑制距最近一次注入不足该毫秒数的注入。
17
+
18
+ ## 如何读取 tmux
19
+
20
+ 插件前置注册一个 `agent/pre-step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器服务运行一条只读命令:
21
+
22
+ ```sh
23
+ [ -n "$TMUX_PANE" ] || exit 1
24
+ self_tty=$(ps -o tty= -p <pid> | tr -d ' ')
25
+ pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
26
+ [ "$pane_tty" = "/dev/$self_tty" ] || exit 1
27
+ exec tmux display-message -t "$TMUX_PANE" -p '<format>'
28
+ ```
29
+
30
+ 仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX` 与 `$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty,而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。由于位置信息是可选的,执行器的拒绝——`resolve()` 的策略拒绝或 `run()` 的基础设施故障——会被兜住并记录为警告,而不会使该轮失败。
31
+
32
+ 状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。
33
+
34
+ ## 时序语义
35
+
36
+ 该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会向返回的批次前置添加一条带来源的 `UserMessage`。AgentLoop 会在 `step/start` 之后记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。下游 pre-step 监听器拒绝或失败时,该读数不会被记录。
37
+
38
+ ## 模型体验
39
+
40
+ ### 准备期 tmux 位置
41
+
42
+ #### 模型看到的内容
43
+
44
+ 在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`<window-layout>` 是 tmux 紧凑的 pane 树描述;pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。
45
+
46
+ ##### 变化轮次读数
47
+
48
+ ```markdown
49
+ tmux location (turn <turn>):
50
+ session <session>, window <index> "<name>", pane <index> <pane-id>
51
+ window active=<0|1>, pane active=<0|1>, layout <window-layout>
52
+ ```
53
+
54
+ #### Token 影响
55
+
56
+ 每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。
57
+
58
+ #### KV Cache 影响
59
+
60
+ 只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV Cache 条目失效。
61
+
62
+ ## 已知限制与后续工作
63
+
64
+ - **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。
65
+ - **仅自身位置**——插件从不采集相邻 pane 的可见文本。
66
+ - **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。
67
+ - **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。
68
+ - **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE` 的 `#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX`/`$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX;在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。
package/lib/index.js ADDED
@@ -0,0 +1,350 @@
1
+ import { createRequire } from "node:module";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import "@deepseek-ai/cordis";
4
+ //#region ../../llm/llm/src/brand.ts
5
+ /**
6
+ * Brand a message identifier.
7
+ * @param id - the opaque message identifier.
8
+ * @returns the same string, branded; no validation is performed.
9
+ */
10
+ function MessageId(id) {
11
+ return id;
12
+ }
13
+ //#endregion
14
+ //#region ../../llm/llm/src/call-config.ts
15
+ /**
16
+ * Deep-freeze a value in place with an iterative traversal, guarding cycles,
17
+ * so later mutation throws without imposing a JavaScript call-stack depth cap.
18
+ * {@link AbortSignal} objects are deliberately skipped because they are the
19
+ * request's live cancellation channel and freezing them breaks abort.
20
+ * @param value - the value to freeze in place.
21
+ * @returns the same value, frozen.
22
+ */
23
+ function deepFreeze(value) {
24
+ const seen = /* @__PURE__ */ new WeakSet();
25
+ const pending = [{
26
+ kind: "visit",
27
+ node: value
28
+ }];
29
+ while (pending.length > 0) {
30
+ const task = pending.pop();
31
+ /* v8 ignore next -- the loop condition guarantees one pending task. */
32
+ if (task === void 0) continue;
33
+ if (task.kind === "property") {
34
+ pending.push({
35
+ kind: "visit",
36
+ node: task.source[task.key]
37
+ });
38
+ continue;
39
+ }
40
+ const node = task.node;
41
+ if (node === null || typeof node !== "object") continue;
42
+ if (node instanceof AbortSignal) continue;
43
+ if (seen.has(node)) continue;
44
+ seen.add(node);
45
+ Object.freeze(node);
46
+ const keys = Object.keys(node);
47
+ for (let index = keys.length - 1; index >= 0; index--) {
48
+ const key = keys[index];
49
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
50
+ if (key === void 0) continue;
51
+ pending.push({
52
+ kind: "property",
53
+ source: node,
54
+ key
55
+ });
56
+ }
57
+ }
58
+ return value;
59
+ }
60
+ //#endregion
61
+ //#region ../../llm/llm/src/message.ts
62
+ /** Message value types, identity, and immutable construction helpers. */
63
+ /**
64
+ * Detach and deep-freeze a message whose identity already exists.
65
+ * @param message - complete message, including its stable identity.
66
+ * @returns an immutable snapshot that preserves the identity.
67
+ */
68
+ function freezeMessage(message) {
69
+ return deepFreeze(structuredClone(message));
70
+ }
71
+ /**
72
+ * Create one identified message and freeze it before publication.
73
+ * @param input - complete role, content, and source for a new message.
74
+ * @returns an immutable message with a fresh stable identity.
75
+ */
76
+ function createMessage(input) {
77
+ return freezeMessage({
78
+ ...input,
79
+ id: MessageId(crypto.randomUUID())
80
+ });
81
+ }
82
+ /**
83
+ * Create one identified user-role message and freeze it before publication.
84
+ * @param input - complete content and source for a new user message.
85
+ * @returns an immutable user message with a fresh stable identity.
86
+ */
87
+ function createUserMessage(input) {
88
+ return createMessage({
89
+ ...input,
90
+ role: "user"
91
+ });
92
+ }
93
+ //#endregion
94
+ //#region ../../util/timeout/src/index.ts
95
+ /** Largest delay Node schedules without clamping it to one millisecond. */
96
+ const MAX_TIMER_DELAY_MS = 2147483647;
97
+ //#endregion
98
+ //#region ../../llm/llm/src/error.ts
99
+ /**
100
+ * Canonical provider-neutral code for a response that completed normally but
101
+ * carried no content blocks at all. Providers occasionally emit a degenerate
102
+ * completion (a terminal stop with zero output); adapters classify it as this
103
+ * failure instead of yielding an empty assistant message, because an empty
104
+ * message silently ends the turn with nothing for the user or the loop to act
105
+ * on. The attempt produced nothing durable, so retry policy treats it as safe
106
+ * to repeat.
107
+ */
108
+ const EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE";
109
+ new RegExp(String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, "i");
110
+ new RegExp(String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, "i");
111
+ new RegExp(String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, "i");
112
+ //#endregion
113
+ //#region ../../llm/llm/src/retry-policy.ts
114
+ /**
115
+ * Provider-owned request-retry policy configuration and resolution.
116
+ *
117
+ * Adapters expose one resolved policy per registered provider route; the
118
+ * optional dsh-llm-retry plugin executes it on the agent's failed-step extension point.
119
+ *
120
+ * @module @deepseek-ai/dsh-llm/retry-policy
121
+ */
122
+ const DEFAULT_MAX_RETRIES = 2;
123
+ const DEFAULT_INITIAL_DELAY_MS = 500;
124
+ const DEFAULT_MAX_DELAY_MS = 1e4;
125
+ const DEFAULT_JITTER_RATIO = .1;
126
+ const DEFAULT_RETRYABLE_CODES = Object.freeze([
127
+ EMPTY_RESPONSE_CODE,
128
+ "RATE_LIMIT",
129
+ "SERVER",
130
+ "TIMEOUT",
131
+ "TRANSPORT"
132
+ ]);
133
+ const backoffSchema = z.object({
134
+ initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
135
+ maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
136
+ jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO)
137
+ });
138
+ const normalPolicySchema = z.object({
139
+ mode: z.const("normal").required(),
140
+ maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
141
+ retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
142
+ backoff: backoffSchema
143
+ });
144
+ const alwaysPolicySchema = z.object({
145
+ mode: z.const("always").required(),
146
+ backoff: backoffSchema
147
+ });
148
+ z.union([normalPolicySchema, alwaysPolicySchema]);
149
+ //#endregion
150
+ //#region ../../llm/llm/src/attribution.ts
151
+ /**
152
+ * Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
153
+ * adapters from drifting. See
154
+ * `.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
155
+ *
156
+ * App-attribution vocabulary for provider requests.
157
+ * @module @deepseek-ai/dsh-llm/attribution
158
+ */
159
+ const { version } = createRequire(import.meta.url)("../package.json");
160
+ //#endregion
161
+ //#region lib/types/index.js
162
+ /**
163
+ * Opt-in request-preparation tmux-location context. Eligible step attempts
164
+ * append durable, source-attributed context naming the tmux session, window,
165
+ * and pane this agent process runs in, plus the window's pane-tree layout.
166
+ *
167
+ * The plugin pulls state once per turn, for the first request (`step === 1`), by
168
+ * running one `tmux display-message` through the `ctx.bash` executor service. It
169
+ * confirms this process genuinely runs inside the pane `$TMUX_PANE` names by
170
+ * matching the pane's `#{pane_tty}` against this process's controlling terminal,
171
+ * so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor
172
+ * (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects
173
+ * only when the rendered tmux state changes since the last injection (a moved,
174
+ * renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor
175
+ * between injections. Absent tmux environment, an inherited-only environment,
176
+ * absent `ctx.bash`, or a failed query is a no-op, never an error: an executor
177
+ * rejection is contained and logged as a warning so the turn continues.
178
+ *
179
+ * @module @deepseek-ai/dsh-tmux-context
180
+ */
181
+ /** Cordis plugin name used by loader diagnostics. */
182
+ const name = "tmux-context";
183
+ /** The agent registry that owns pre-step processing. */
184
+ const inject = ["agents"];
185
+ /** Schemastery validation for {@link Config}. */
186
+ const Config = z.object({ refreshIntervalMs: z.number() });
187
+ /**
188
+ * Tab-separated tmux format fields, in query order. Layout (`window_layout`)
189
+ * is the pane-tree description; pane/window pixel sizes are intentionally
190
+ * excluded (own location and layout only, per the package scope).
191
+ */
192
+ const TMUX_FIELDS = [
193
+ "#{session_name}",
194
+ "#{window_index}",
195
+ "#{window_name}",
196
+ "#{pane_index}",
197
+ "#{pane_id}",
198
+ "#{window_active}",
199
+ "#{pane_active}",
200
+ "#{window_layout}"
201
+ ];
202
+ /** Prefix marking the volatile turn/step preamble line of a rendered reading. */
203
+ const READING_PREFIX = "tmux location (turn ";
204
+ /**
205
+ * Field separator between tmux format fields. tmux does not interpret C escapes
206
+ * in a format, so the literal two-character sequence `\t` is emitted verbatim
207
+ * and split back out here; this avoids embedding raw whitespace in the command.
208
+ */
209
+ const FIELD_SEP = "\\t";
210
+ /**
211
+ * Read this process's tmux location through the bash seam, or `undefined` when
212
+ * this process is not genuinely running inside a tmux pane or the query fails.
213
+ *
214
+ * `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell
215
+ * (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and
216
+ * `$TMUX_PANE` from that ancestor, so the variables are present even though this
217
+ * process does not live in that pane. The command therefore also compares the
218
+ * pane's `#{pane_tty}` against this process's own controlling terminal
219
+ * (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty,
220
+ * an inherited environment names some other pane's tty. Fields are emitted only
221
+ * on a match, so an inherited environment reads as "not in tmux" and injects
222
+ * nothing.
223
+ *
224
+ * The location is optional context, so an executor rejection is a failed query,
225
+ * not a turn failure: `resolve()` may reject the command on policy grounds and
226
+ * `run()` only promises to resolve for nonzero exits, timeouts, and aborts, so
227
+ * both are contained and reported as a warning.
228
+ *
229
+ * @param bash - The executor service used to run the read-only tmux/ps commands.
230
+ * @param logger - receives a warning when the executor rejects the query.
231
+ * @param processId - this agent process's pid, whose controlling tty must match the pane.
232
+ * @param signal - abort signal forwarded to the executor.
233
+ * @returns the parsed location, or `undefined` when not in a real pane or on any failure.
234
+ */
235
+ async function queryTmuxLocation(bash, logger, processId, signal) {
236
+ const format = TMUX_FIELDS.join(FIELD_SEP);
237
+ const command = [
238
+ "[ -n \"$TMUX_PANE\" ] || exit 1",
239
+ `self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`,
240
+ "[ -n \"$self_tty\" ] || exit 1",
241
+ "pane_tty=$(tmux display-message -t \"$TMUX_PANE\" -p '#{pane_tty}') || exit 1",
242
+ "[ \"$pane_tty\" = \"/dev/$self_tty\" ] || exit 1",
243
+ `exec tmux display-message -t "$TMUX_PANE" -p '${format}'`
244
+ ].join("\n");
245
+ let result;
246
+ try {
247
+ result = await bash.run(bash.resolve({
248
+ command,
249
+ signal
250
+ }));
251
+ } catch (error) {
252
+ const message = error instanceof Error ? error.message : String(error);
253
+ logger.warn(`tmux location query failed: ${message}; injecting no location this turn`);
254
+ return;
255
+ }
256
+ if (result.exitCode !== 0) return void 0;
257
+ const parts = result.stdout.text.split("\n", 1)[0].split(FIELD_SEP);
258
+ if (parts.length !== TMUX_FIELDS.length) return void 0;
259
+ const [sessionName, windowIndex, windowName, paneIndex, paneId, windowActive, paneActive, windowLayout] = parts;
260
+ if (paneId.length === 0) return void 0;
261
+ return {
262
+ sessionName,
263
+ windowIndex,
264
+ windowName,
265
+ paneIndex,
266
+ paneId,
267
+ windowActive,
268
+ paneActive,
269
+ windowLayout
270
+ };
271
+ }
272
+ /**
273
+ * Render the stable tmux state block: the part of a reading compared for
274
+ * change suppression. It excludes the turn preamble so re-injection is driven
275
+ * only by tmux state, not by loop position.
276
+ */
277
+ function renderState(location) {
278
+ return `session ${location.sessionName}, window ${location.windowIndex} ${JSON.stringify(location.windowName)}, pane ${location.paneIndex} ${location.paneId}\nwindow active=${location.windowActive}, pane active=${location.paneActive}, layout ${location.windowLayout}`;
279
+ }
280
+ /** Render the full durable reading, including the volatile turn preamble. */
281
+ function renderReading(location, turn) {
282
+ return `${READING_PREFIX}${turn}):\n${renderState(location)}`;
283
+ }
284
+ /**
285
+ * The stable state block of this plugin's latest durable injection, or
286
+ * `undefined` when the session has none. Scans raw durable events so the
287
+ * schedule survives compaction and resumed processes without process-local
288
+ * cache state.
289
+ */
290
+ function latestInjectedState(agent) {
291
+ for (const event of [...agent.session.events].reverse()) if (event.type === "user/message" && event.data.source.kind === "plugin" && event.data.source.plugin === "tmux-context") {
292
+ const [block] = event.data.content;
293
+ if (block?.type !== "text") return void 0;
294
+ const newline = block.text.indexOf("\n");
295
+ return {
296
+ state: newline === -1 ? "" : block.text.slice(newline + 1),
297
+ time: event.time
298
+ };
299
+ }
300
+ }
301
+ /** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
302
+ function validateRefreshInterval(refreshIntervalMs) {
303
+ if (refreshIntervalMs !== void 0 && (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0)) throw new TypeError(`tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`);
304
+ }
305
+ /**
306
+ * Register a prepended pre-step listener for the lifetime of `ctx`.
307
+ * @param ctx - plugin context; the listener is disposed with it.
308
+ * @param config - durable refresh scheduling configuration.
309
+ * @throws when the refresh interval is invalid.
310
+ */
311
+ function apply(ctx, config) {
312
+ const refreshIntervalMs = config.refreshIntervalMs;
313
+ validateRefreshInterval(refreshIntervalMs);
314
+ ctx.on("agent/pre-step", async ({ agent, turn, step, signal }, next) => {
315
+ const decision = await next();
316
+ if (decision.kind === "reject" || signal.aborted || step !== 1) return decision;
317
+ const bash = ctx.get("bash");
318
+ if (bash === void 0) return decision;
319
+ const previous = latestInjectedState(agent);
320
+ if (refreshIntervalMs !== void 0 && refreshIntervalMs > 0 && previous !== void 0) {
321
+ const now = Date.now();
322
+ if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision;
323
+ }
324
+ const location = await queryTmuxLocation(bash, ctx.logger, process.pid, signal);
325
+ if (location === void 0) return decision;
326
+ const state = renderState(location);
327
+ if (previous !== void 0 && previous.state === state) return decision;
328
+ const text = renderReading(location, turn);
329
+ return {
330
+ kind: "enter",
331
+ messages: [createUserMessage({
332
+ content: [{
333
+ type: "text",
334
+ text
335
+ }],
336
+ source: {
337
+ kind: "plugin",
338
+ plugin: name,
339
+ form: "snapshot",
340
+ sections: [{
341
+ name,
342
+ text
343
+ }]
344
+ }
345
+ }), ...decision.messages]
346
+ };
347
+ }, { prepend: true });
348
+ }
349
+ //#endregion
350
+ export { Config, apply, inject, name };
@@ -0,0 +1,23 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`.
4
+ * @module @deepseek-ai/dsh-tmux-context/invariant
5
+ */
6
+ const PACKAGE_NAME = "@deepseek-ai/dsh-tmux-context";
7
+ /** Cordis companion plugin name. */
8
+ const name = "tmux-context-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session
13
+ * holds no cross-event relation to check; scheduling and format are owned by pipeline tests.
14
+ */
15
+ const install = () => {};
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ //#endregion
23
+ export { apply, inject, name };
@@ -0,0 +1,40 @@
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.bash` 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.bash`, 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 } from '@deepseek-ai/cordis';
21
+ import z from '@deepseek-ai/schemastery';
22
+ /** Cordis plugin name used by loader diagnostics. */
23
+ export declare const name = "tmux-context";
24
+ /** The agent registry that owns pre-step processing. */
25
+ export declare const inject: string[];
26
+ /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */
27
+ export interface Config {
28
+ /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */
29
+ refreshIntervalMs?: number;
30
+ }
31
+ /** Schemastery validation for {@link Config}. */
32
+ export declare const Config: z<Config>;
33
+ /**
34
+ * Register a prepended pre-step listener for the lifetime of `ctx`.
35
+ * @param ctx - plugin context; the listener is disposed with it.
36
+ * @param config - durable refresh scheduling configuration.
37
+ * @throws when the refresh interval is invalid.
38
+ */
39
+ export declare function apply(ctx: Context, config: Config): void;
40
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`.
3
+ * @module @deepseek-ai/dsh-tmux-context/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "tmux-context-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@deepseek-ai/dsh-tmux-context",
3
+ "description": "Opt-in durable per-step context with this agent's tmux pane and window location",
4
+ "version": "0.0.1-rc.1",
5
+ "publishConfig": {
6
+ "access": "restricted"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/context/tmux-context"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib/index.js",
30
+ "lib/invariant.js",
31
+ "lib/types/**/*.d.ts"
32
+ ],
33
+ "license": "BSD-3-Clause",
34
+ "dependencies": {
35
+ "@deepseek-ai/schemastery": "^3.18.1-rc.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
39
+ "@deepseek-ai/dsh-bash": "^0.0.1-rc.1",
40
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
41
+ "@deepseek-ai/cordis": "^4.0.1-rc.1",
42
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.1"
43
+ },
44
+ "devDependencies": {
45
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
46
+ "@deepseek-ai/dsh-bash": "^0.0.1-rc.1",
47
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
48
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
49
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.1",
50
+ "@deepseek-ai/cordis": "^4.0.1-rc.1",
51
+ "@deepseek-ai/dsh-system-prompt": "^0.0.1-rc.1"
52
+ }
53
+ }