@f5-sales-demo/xcsh 19.91.0 → 19.91.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.91.0",
4
+ "version": "19.91.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "1.3.0",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.91.0",
60
- "@f5-sales-demo/pi-agent-core": "19.91.0",
61
- "@f5-sales-demo/pi-ai": "19.91.0",
62
- "@f5-sales-demo/pi-natives": "19.91.0",
63
- "@f5-sales-demo/pi-resource-management": "19.91.0",
64
- "@f5-sales-demo/pi-tui": "19.91.0",
65
- "@f5-sales-demo/pi-utils": "19.91.0",
59
+ "@f5-sales-demo/xcsh-stats": "19.91.1",
60
+ "@f5-sales-demo/pi-agent-core": "19.91.1",
61
+ "@f5-sales-demo/pi-ai": "19.91.1",
62
+ "@f5-sales-demo/pi-natives": "19.91.1",
63
+ "@f5-sales-demo/pi-resource-management": "19.91.1",
64
+ "@f5-sales-demo/pi-tui": "19.91.1",
65
+ "@f5-sales-demo/pi-utils": "19.91.1",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -29,6 +29,24 @@ import type { ExtensionAPI, ExtensionContext } from "@f5-sales-demo/xcsh";
29
29
  * The extension is completely inert unless it is running inside a herdr pane
30
30
  * (detected via `HERDR_PANE_ID`), so it has zero effect for users who do not run
31
31
  * xcsh under herdr.
32
+ *
33
+ * Sequencing contract with herdr — do not regress. herdr keeps one monotonic
34
+ * `seq` per *source*, shared across every method, and silently discards any frame
35
+ * whose `seq` is not greater than the last it accepted for `herdr:xcsh`. Two
36
+ * consequences drive the design below, and both previously cost panes their
37
+ * resume reference:
38
+ *
39
+ * 1. `seq` is seeded from the wall clock, not 0, so a restarted xcsh always
40
+ * outranks its predecessor in the same pane. With a per-process counter from
41
+ * 0, every frame from the second xcsh in a pane looks stale and is dropped.
42
+ * 2. Frames go out one at a time through `enqueue`. They used to be
43
+ * fire-and-forget on independent sockets, so ordering was left to chance.
44
+ * 3. The state frame is always sent *before* the session frame. herdr discards a
45
+ * `pane.report_agent_session` for a pane whose agent it does not yet own, so
46
+ * the state frame has to establish `herdr:xcsh` as the pane's agent first.
47
+ * Verified against a live herdr: session-then-state (even with a correctly
48
+ * ascending `seq`) leaves `agent_session` null, while state-then-session
49
+ * records it.
32
50
  */
33
51
 
34
52
  const HERDR_AGENT_LABEL = "xcsh";
@@ -42,26 +60,43 @@ const SOCKET_TIMEOUT_MS = 2000;
42
60
 
43
61
  /**
44
62
  * Write one newline-delimited JSON-RPC request to herdr's unix socket and close.
45
- * Fire-and-forget: the response is ignored and every failure path is reported
46
- * via `onError` without throwing, so a dead socket never disturbs the agent.
63
+ * The response is ignored and every failure path is reported via `onError`
64
+ * without throwing, so a dead socket never disturbs the agent.
65
+ *
66
+ * Resolves once the frame has been flushed (or the attempt failed or timed out),
67
+ * which is what lets callers order frames relative to one another.
47
68
  */
48
69
  function sendToHerdrSocket(
49
70
  socketPath: string,
50
71
  method: string,
51
72
  params: Record<string, unknown>,
52
73
  onError: (err: unknown) => void,
53
- ): void {
54
- const conn = connect({ path: socketPath });
55
- // Never let a report keep xcsh's event loop alive.
56
- conn.unref();
57
- conn.once("error", err => {
58
- onError(err);
59
- conn.destroy();
60
- });
61
- conn.once("connect", () => {
62
- conn.end(`${JSON.stringify({ id: "xcsh:herdr-reporter", method, params })}\n`);
74
+ ): Promise<void> {
75
+ return new Promise<void>(resolve => {
76
+ let settled = false;
77
+ const settle = (): void => {
78
+ if (settled) {
79
+ return;
80
+ }
81
+ settled = true;
82
+ resolve();
83
+ };
84
+ const conn = connect({ path: socketPath });
85
+ // Never let a report keep xcsh's event loop alive.
86
+ conn.unref();
87
+ conn.once("error", err => {
88
+ onError(err);
89
+ conn.destroy();
90
+ settle();
91
+ });
92
+ conn.once("connect", () => {
93
+ conn.end(`${JSON.stringify({ id: "xcsh:herdr-reporter", method, params })}\n`, settle);
94
+ });
95
+ conn.setTimeout(SOCKET_TIMEOUT_MS, () => {
96
+ conn.destroy();
97
+ settle();
98
+ });
63
99
  });
64
- conn.setTimeout(SOCKET_TIMEOUT_MS, () => conn.destroy());
65
100
  }
66
101
 
67
102
  /** Translate a JSON-RPC report/release into `herdr` CLI arguments (fallback). */
@@ -90,10 +125,19 @@ export default function herdrReporter(pi: ExtensionAPI): void {
90
125
  return;
91
126
  }
92
127
 
93
- let seq = 0;
128
+ // Seeded from the wall clock at microsecond scale rather than 0 so a new xcsh
129
+ // process in a pane always starts above whatever the previous process reached.
130
+ // Matches the pi/omp reporters and stays well inside Number.MAX_SAFE_INTEGER.
131
+ let seq = Date.now() * 1000;
94
132
  // Tracks whether an interactive prompt is currently awaiting the user, so an
95
133
  // agent_end that fires while a prompt is open is reported as blocked, not idle.
96
134
  let promptOpen = false;
135
+ // Last session ref already delivered, so repeated lifecycle events do not
136
+ // re-send an unchanged ref every turn.
137
+ let lastSessionRefKey: string | undefined;
138
+ // Serializes frames: herdr compares `seq` across all methods for this source,
139
+ // so frames must reach it in the order they were generated.
140
+ let queue: Promise<void> = Promise.resolve();
97
141
 
98
142
  pi.setLabel(HERDR_AGENT_LABEL);
99
143
 
@@ -103,17 +147,27 @@ export default function herdrReporter(pi: ExtensionAPI): void {
103
147
  });
104
148
  };
105
149
 
106
- const send = (method: string, params: Record<string, unknown>): void => {
150
+ /** Chain a frame onto the tail of the send queue. Never rejects. */
151
+ const enqueue = (task: () => Promise<void>): Promise<void> => {
152
+ queue = queue.then(task, task).catch(onError);
153
+ return queue;
154
+ };
155
+
156
+ const send = (method: string, params: Record<string, unknown>): Promise<void> => {
107
157
  const socketPath = process.env.HERDR_SOCKET_PATH;
108
158
  if (socketPath) {
109
- sendToHerdrSocket(socketPath, method, params, onError);
110
- return;
159
+ return enqueue(() => sendToHerdrSocket(socketPath, method, params, onError));
111
160
  }
112
161
  // Fallback for the rare case herdr did not inject a socket path.
113
- pi.exec("herdr", toCliArgs(method, params)).catch(onError);
162
+ return enqueue(() =>
163
+ pi
164
+ .exec("herdr", toCliArgs(method, params))
165
+ .then(() => undefined)
166
+ .catch(onError),
167
+ );
114
168
  };
115
169
 
116
- const report = (state: "idle" | "working" | "blocked"): void => {
170
+ const report = (state: "idle" | "working" | "blocked"): Promise<void> =>
117
171
  send(REPORT_METHOD, {
118
172
  pane_id: paneId,
119
173
  source: HERDR_SOURCE,
@@ -121,7 +175,6 @@ export default function herdrReporter(pi: ExtensionAPI): void {
121
175
  state,
122
176
  seq: seq++,
123
177
  });
124
- };
125
178
 
126
179
  // Report the current session's identity so herdr can resume this pane
127
180
  // (`xcsh --resume=<session>`) after a server restart. This is sent only over
@@ -129,10 +182,16 @@ export default function herdrReporter(pi: ExtensionAPI): void {
129
182
  // reports via the CLI fallback). Prefer the absolute session file path, which
130
183
  // herdr resumes directly; fall back to the session id for non-persisted
131
184
  // sessions (e.g. print/RPC mode, where getSessionFile() is undefined).
132
- const reportSession = (ctx: ExtensionContext): void => {
185
+ //
186
+ // Called from every lifecycle handler because xcsh creates the session file
187
+ // lazily: getSessionFile() can still be undefined at session_start and even at
188
+ // agent_start, and a ref missed there would otherwise be lost until the next
189
+ // turn — long enough for a restart to lose the pane. Unchanged refs are
190
+ // suppressed, so the extra call sites cost nothing on the wire.
191
+ const reportSession = (ctx: ExtensionContext): Promise<void> => {
133
192
  const socketPath = process.env.HERDR_SOCKET_PATH;
134
193
  if (!socketPath) {
135
- return;
194
+ return Promise.resolve();
136
195
  }
137
196
  let sessionRef: Record<string, unknown> | undefined;
138
197
  try {
@@ -147,58 +206,63 @@ export default function herdrReporter(pi: ExtensionAPI): void {
147
206
  }
148
207
  } catch (err) {
149
208
  onError(err);
150
- return;
209
+ return Promise.resolve();
151
210
  }
152
211
  if (!sessionRef) {
153
- return;
212
+ return Promise.resolve();
154
213
  }
155
- sendToHerdrSocket(
156
- socketPath,
157
- SESSION_METHOD,
158
- {
159
- pane_id: paneId,
160
- source: HERDR_SOURCE,
161
- agent: HERDR_AGENT_LABEL,
162
- seq: seq++,
163
- ...sessionRef,
164
- },
165
- onError,
166
- );
214
+ const refKey = JSON.stringify(sessionRef);
215
+ if (refKey === lastSessionRefKey) {
216
+ return Promise.resolve();
217
+ }
218
+ lastSessionRefKey = refKey;
219
+ const frame = {
220
+ pane_id: paneId,
221
+ source: HERDR_SOURCE,
222
+ agent: HERDR_AGENT_LABEL,
223
+ seq: seq++,
224
+ ...sessionRef,
225
+ };
226
+ return enqueue(() => sendToHerdrSocket(socketPath, SESSION_METHOD, frame, onError));
167
227
  };
168
228
 
169
229
  // Announce presence and session identity as soon as the session is initialized.
170
- pi.on("session_start", (_event, ctx) => {
171
- reportSession(ctx);
172
- report("idle");
230
+ pi.on("session_start", async (_event, ctx) => {
231
+ await report("idle");
232
+ await reportSession(ctx);
173
233
  });
174
234
 
175
235
  // Busy while the agent loop is streaming a response. Re-report session identity
176
236
  // in case the active session file changed (e.g. after /new, /resume, or /fork).
177
- pi.on("agent_start", (_event, ctx) => {
178
- reportSession(ctx);
179
- report("working");
237
+ pi.on("agent_start", async (_event, ctx) => {
238
+ await report("working");
239
+ await reportSession(ctx);
180
240
  });
181
241
 
182
242
  // Back to idle when the loop ends — unless we are waiting on a user prompt.
183
- pi.on("agent_end", () => {
184
- report(promptOpen ? "blocked" : "idle");
243
+ // This is also the first point where a lazily-created session file is certain
244
+ // to exist, so it is the backstop for capturing the resume ref.
245
+ pi.on("agent_end", async (_event, ctx) => {
246
+ await report(promptOpen ? "blocked" : "idle");
247
+ await reportSession(ctx);
185
248
  });
186
249
 
187
250
  // An interactive prompt (permission gate, ask tool, confirm/input) is
188
251
  // awaiting the user: that is herdr's "needs attention" (blocked) state.
189
252
  pi.on("user_prompt_start", () => {
190
253
  promptOpen = true;
191
- report("blocked");
254
+ void report("blocked");
192
255
  });
193
256
 
194
- pi.on("user_prompt_end", (_event, ctx) => {
257
+ pi.on("user_prompt_end", async (_event, ctx) => {
195
258
  promptOpen = false;
196
- report(ctx.isIdle() ? "idle" : "working");
259
+ await report(ctx.isIdle() ? "idle" : "working");
260
+ await reportSession(ctx);
197
261
  });
198
262
 
199
263
  // Relinquish pane authority so herdr stops showing xcsh once we exit.
200
264
  pi.on("session_shutdown", () => {
201
- send(RELEASE_METHOD, {
265
+ void send(RELEASE_METHOD, {
202
266
  pane_id: paneId,
203
267
  source: HERDR_SOURCE,
204
268
  agent: HERDR_AGENT_LABEL,
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.91.0",
21
- "commit": "37a9e074a2203b5faeee3e41f7a2d7704f120c4f",
22
- "shortCommit": "37a9e07",
20
+ "version": "19.91.1",
21
+ "commit": "cb975aa015ff199ecdc38c20544471ebf32e3737",
22
+ "shortCommit": "cb975aa",
23
23
  "branch": "main",
24
- "tag": "v19.91.0",
25
- "commitDate": "2026-07-25T15:33:10Z",
26
- "buildDate": "2026-07-25T15:59:23.847Z",
24
+ "tag": "v19.91.1",
25
+ "commitDate": "2026-07-25T17:10:56Z",
26
+ "buildDate": "2026-07-25T17:31:09.895Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/37a9e074a2203b5faeee3e41f7a2d7704f120c4f",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.91.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/cb975aa015ff199ecdc38c20544471ebf32e3737",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.91.1"
33
33
  };