@f5-sales-demo/xcsh 19.85.4 → 19.85.6

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.85.4",
4
+ "version": "19.85.6",
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": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.85.4",
60
- "@f5-sales-demo/pi-agent-core": "19.85.4",
61
- "@f5-sales-demo/pi-ai": "19.85.4",
62
- "@f5-sales-demo/pi-natives": "19.85.4",
63
- "@f5-sales-demo/pi-resource-management": "19.85.4",
64
- "@f5-sales-demo/pi-tui": "19.85.4",
65
- "@f5-sales-demo/pi-utils": "19.85.4",
59
+ "@f5-sales-demo/xcsh-stats": "19.85.6",
60
+ "@f5-sales-demo/pi-agent-core": "19.85.6",
61
+ "@f5-sales-demo/pi-ai": "19.85.6",
62
+ "@f5-sales-demo/pi-natives": "19.85.6",
63
+ "@f5-sales-demo/pi-resource-management": "19.85.6",
64
+ "@f5-sales-demo/pi-tui": "19.85.6",
65
+ "@f5-sales-demo/pi-utils": "19.85.6",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -8,6 +8,8 @@
8
8
  * mirroring `stats-cli.ts` / `chrome-cli.ts`.
9
9
  */
10
10
  import { spawnSync } from "node:child_process";
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
11
13
  import * as path from "node:path";
12
14
  import { LOCALIP_HOST } from "../browser/bridge-cert";
13
15
  import { type HeadlessChatBridge, startHeadlessChatBridge } from "../browser/headless-bridge";
@@ -133,7 +135,49 @@ async function runServe(): Promise<void> {
133
135
  });
134
136
  }
135
137
 
136
- /** Run the Office sideload against the embedded bundle (best-effort). */
138
+ /** The macOS Office desktop containers whose `wef` folder holds sideloaded manifests. */
139
+ const OFFICE_WEF_CONTAINERS = ["com.microsoft.Excel", "com.microsoft.Powerpoint", "com.microsoft.Word"];
140
+
141
+ /** The `wef` sideload directories for the desktop Office apps under `homeDir`. */
142
+ export function officeWefDirs(homeDir: string): string[] {
143
+ return OFFICE_WEF_CONTAINERS.map(c => path.join(homeDir, "Library", "Containers", c, "Data", "Documents", "wef"));
144
+ }
145
+
146
+ /**
147
+ * `office-addin-debugging` symlinks the add-in manifest into each Office container's
148
+ * `wef` folder as `<manifestId>.manifest.json` and fails `EEXIST` if a link from a
149
+ * prior sideload is already there — so a repeat sideload errors even though the
150
+ * add-in registered fine. Remove any stale copy for our id so `office sideload` is
151
+ * idempotent. Best-effort per dir (a missing file or container is a no-op). Returns
152
+ * the paths actually removed.
153
+ */
154
+ export function removeStaleWefManifests(manifestId: string, homeDir: string = os.homedir()): string[] {
155
+ const removed: string[] = [];
156
+ for (const dir of officeWefDirs(homeDir)) {
157
+ const p = path.join(dir, `${manifestId}.manifest.json`);
158
+ try {
159
+ if (fs.existsSync(p)) {
160
+ fs.rmSync(p);
161
+ removed.push(p);
162
+ }
163
+ } catch {
164
+ /* best-effort: a permission error or race here must not block the sideload */
165
+ }
166
+ }
167
+ return removed;
168
+ }
169
+
170
+ /** The add-in manifest id from the bundled manifest.json (the `wef` link's basename). */
171
+ function manifestIdFrom(manifestText: string): string | undefined {
172
+ try {
173
+ const id = (JSON.parse(manifestText) as { id?: unknown }).id;
174
+ return typeof id === "string" && id.length > 0 ? id : undefined;
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ /** Run the Office sideload against the embedded bundle (idempotent + best-effort). */
137
181
  async function runSideload(app: OfficeApp): Promise<void> {
138
182
  // Point office-addin-debugging at the extracted bundle dir, which has
139
183
  // manifest.json AND its referenced `assets/` icons colocated. A bare temp
@@ -141,6 +185,21 @@ async function runSideload(app: OfficeApp): Promise<void> {
141
185
  // step: `File to zip ".../assets/color.png" does not exist`.
142
186
  const dir = await getOfficePaneDir();
143
187
  const manifestPath = path.join(dir, "manifest.json");
188
+
189
+ // Idempotency: office-addin-debugging fails EEXIST if a prior sideload left a
190
+ // `<id>.manifest.json` link in a container's wef folder. Clear stale copies first.
191
+ const manifestId = manifestIdFrom(
192
+ await Bun.file(manifestPath)
193
+ .text()
194
+ .catch(() => ""),
195
+ );
196
+ if (manifestId) {
197
+ const removed = removeStaleWefManifests(manifestId);
198
+ if (removed.length > 0) {
199
+ console.log(`Cleared ${removed.length} stale sideload manifest link(s) from a previous sideload.`);
200
+ }
201
+ }
202
+
144
203
  console.log(`Sideloading ${manifestPath} into ${app} (requires the office-addin-debugging / atk tool on PATH)...`);
145
204
 
146
205
  const result = spawnSync("office-addin-debugging", ["start", manifestPath, "desktop", "--app", app], {
@@ -82,13 +82,25 @@ class RpcHostToolAdapter<TParams extends TSchema = TSchema, TTheme extends Theme
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * How long a host tool may go with NO response — neither a `host_tool_result` nor a
87
+ * `host_tool_update` — before we give up on it. An unanswered call (e.g. an Office
88
+ * task-pane whose WebView the host suspended when it lost focus) would otherwise hang
89
+ * the agent turn forever, and since turns are queued behind the active one, every
90
+ * later turn wedges too. This is IDLE time, reset by each streamed update, so a
91
+ * legitimately slow-but-progressing tool is never cut off — only true silence trips it.
92
+ */
93
+ export const HOST_TOOL_IDLE_TIMEOUT_MS = 60_000;
94
+
85
95
  export class RpcHostToolBridge {
86
96
  #output: RpcHostToolOutput;
87
97
  #definitions = new Map<string, RpcHostToolDefinition>();
88
98
  #pendingCalls = new Map<string, PendingHostToolCall>();
99
+ #idleTimeoutMs: number;
89
100
 
90
- constructor(output: RpcHostToolOutput) {
101
+ constructor(output: RpcHostToolOutput, opts?: { idleTimeoutMs?: number }) {
91
102
  this.#output = output;
103
+ this.#idleTimeoutMs = opts?.idleTimeoutMs ?? HOST_TOOL_IDLE_TIMEOUT_MS;
92
104
  }
93
105
 
94
106
  getToolNames(): string[] {
@@ -141,7 +153,23 @@ export class RpcHostToolBridge {
141
153
  const { promise, resolve, reject } = Promise.withResolvers<AgentToolResult<unknown>>();
142
154
  let settled = false;
143
155
 
156
+ // ---- idle timer (reset by updates; fires → cancel + reject) ----
157
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
158
+ const clearIdle = (): void => {
159
+ if (idleTimer !== undefined) {
160
+ clearTimeout(idleTimer);
161
+ idleTimer = undefined;
162
+ }
163
+ };
164
+ const armIdle = (): void => {
165
+ clearIdle();
166
+ idleTimer = setTimeout(onIdleTimeout, this.#idleTimeoutMs);
167
+ // Don't keep the process alive just for this backstop.
168
+ if (typeof idleTimer === "object" && "unref" in idleTimer) idleTimer.unref();
169
+ };
170
+
144
171
  const cleanup = () => {
172
+ clearIdle();
145
173
  signal?.removeEventListener("abort", onAbort);
146
174
  this.#pendingCalls.delete(id);
147
175
  };
@@ -158,6 +186,23 @@ export class RpcHostToolBridge {
158
186
  reject(new Error(`Host tool "${definition.name}" was aborted`));
159
187
  };
160
188
 
189
+ const onIdleTimeout = () => {
190
+ if (settled) return;
191
+ settled = true;
192
+ cleanup();
193
+ this.#output({
194
+ type: "host_tool_cancel",
195
+ id: Snowflake.next() as string,
196
+ targetId: id,
197
+ });
198
+ reject(
199
+ new Error(
200
+ `Host tool "${definition.name}" did not respond within ${this.#idleTimeoutMs / 1000}s ` +
201
+ "(the host may be unresponsive — a suspended Office WebView loses its connection)",
202
+ ),
203
+ );
204
+ };
205
+
161
206
  signal?.addEventListener("abort", onAbort, { once: true });
162
207
  this.#pendingCalls.set(id, {
163
208
  resolve: result => {
@@ -172,7 +217,11 @@ export class RpcHostToolBridge {
172
217
  cleanup();
173
218
  reject(error);
174
219
  },
175
- onUpdate,
220
+ onUpdate: partial => {
221
+ // A streamed update proves the host is alive — reset the idle clock.
222
+ armIdle();
223
+ onUpdate?.(partial);
224
+ },
176
225
  });
177
226
 
178
227
  this.#output({
@@ -183,6 +232,9 @@ export class RpcHostToolBridge {
183
232
  arguments: args,
184
233
  });
185
234
 
235
+ // Start the idle clock AFTER sending the call.
236
+ armIdle();
237
+
186
238
  return promise;
187
239
  }
188
240
 
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.85.4",
21
- "commit": "d014668cc60902dc4545082fc2c4c2062dff4046",
22
- "shortCommit": "d014668",
20
+ "version": "19.85.6",
21
+ "commit": "58616737ba891a0d71cbdec7314292ed2efbd38f",
22
+ "shortCommit": "5861673",
23
23
  "branch": "main",
24
- "tag": "v19.85.4",
25
- "commitDate": "2026-07-23T21:02:17Z",
26
- "buildDate": "2026-07-23T21:25:20.917Z",
24
+ "tag": "v19.85.6",
25
+ "commitDate": "2026-07-23T23:21:56Z",
26
+ "buildDate": "2026-07-23T23:42:32.289Z",
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/d014668cc60902dc4545082fc2c4c2062dff4046",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.85.4"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/58616737ba891a0d71cbdec7314292ed2efbd38f",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.85.6"
33
33
  };