product-console-companion 0.5.0 → 0.5.2

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/README.md CHANGED
@@ -88,11 +88,22 @@ npx product-console-companion run
88
88
  (this is what `connect` already ran for you, unless you passed `--no-run`) polls each configured
89
89
  connection every few seconds until Ctrl+C. Nothing runs while this isn't running — see the parent
90
90
  plan's Section 7 for why that's deliberate (goal 4: no AI spend while you're not around to
91
- notice).
91
+ notice). The companion periodically confirms that it is still watching while idle and always
92
+ reports why its process exits, including an unexpected exit that should be restarted.
93
+
94
+ For an unattended run, use a simple supervisor loop so the companion comes back even if its
95
+ process is stopped by something outside its control:
96
+
97
+ ```powershell
98
+ while ($true) { npx product-console-companion@latest run; Write-Host "companion exited - restarting in 5s"; Start-Sleep 5 }
99
+ ```
100
+
101
+ With this loop, Ctrl+C exits the current companion and then starts it again. Close the terminal
102
+ window to stop it for good.
92
103
 
93
104
  ## What's NOT yet implemented (be aware before relying on this)
94
105
 
95
- - **Per-task budget is not enforced client-side for the `api-key` backend.** Every dispatched
106
+ - **The `api-key` backend does not enforce the per-task budget.** Every dispatched
96
107
  task carries a `maxBudgetUsd` (visible in the `run` log line); `api-key-caller.ts` does not
97
108
  track spend against it or abort a call that exceeds it. The `cli` backend passes it straight
98
109
  through as `claude`'s own `--max-budget-usd`, which the CLI does enforce (confirmed
package/dist/cli.js CHANGED
@@ -7,6 +7,41 @@ import { CompanionClient, CompanionHttpError } from './http-client.js';
7
7
  import { assertIsRepo, currentCommit } from './local-scan.js';
8
8
  import { runAnalyseShipmentTask, runDesignPromptTask, runGenerateSpecBatchTask, runGenericTask, runProposeNewCapabilitiesTask, runProposeSkeletonTask, runVerifyModuleTask } from './api-key-caller.js';
9
9
  import { runAnalyseShipmentTaskViaCli, runDesignPromptTaskViaCli, runGenerateSpecBatchTaskViaCli, runGenericTaskViaCli, runProposeNewCapabilitiesTaskViaCli, runProposeSkeletonTaskViaCli, runVerifyModuleTaskViaCli } from './claude-cli-caller.js';
10
+ let expectedExitReason = null;
11
+ let unexpectedExitReason = null;
12
+ function asError(reason) {
13
+ return reason instanceof Error ? reason : new Error(String(reason));
14
+ }
15
+ function logFatal(label, reason) {
16
+ const error = asError(reason);
17
+ console.error(`${label}: ${error.message}`);
18
+ console.error(error.stack ?? '(no stack available)');
19
+ return error;
20
+ }
21
+ process.on('uncaughtException', (reason) => {
22
+ const error = logFatal('FATAL uncaught exception', reason);
23
+ unexpectedExitReason = `uncaught exception: ${error.message}`;
24
+ process.exit(1);
25
+ });
26
+ process.on('unhandledRejection', (reason) => {
27
+ const error = logFatal('FATAL unhandled rejection', reason);
28
+ unexpectedExitReason = `unhandled rejection: ${error.message}`;
29
+ process.exit(1);
30
+ });
31
+ process.on('exit', (code) => {
32
+ const timestamp = new Date().toISOString();
33
+ if (unexpectedExitReason) {
34
+ const reason = /[.!?]$/.test(unexpectedExitReason) ? unexpectedExitReason : `${unexpectedExitReason}.`;
35
+ console.error(`[${timestamp}] Companion exited unexpectedly (code ${code}): ${reason} Restart it to keep watching for work.`);
36
+ }
37
+ else if (expectedExitReason) {
38
+ const reason = /[.!?]$/.test(expectedExitReason) ? expectedExitReason : `${expectedExitReason}.`;
39
+ console.log(`[${timestamp}] Companion exited normally (code ${code}): ${reason}`);
40
+ }
41
+ else {
42
+ console.error(`[${timestamp}] Companion exited unexpectedly (code ${code}) without completing its command. Restart it to keep watching for work.`);
43
+ }
44
+ });
10
45
  /** A flag with nothing after it (end of argv, or the next token is itself another "--flag") is
11
46
  * treated as a bare boolean ("true") rather than swallowing the following flag as its value —
12
47
  * needed for --no-run below. Every existing "--flag value" pair still parses exactly as before. */
@@ -136,6 +171,10 @@ const IDLE_POLL_MS = 5000;
136
171
  /** How often to extend a claimed task's lease while it's in flight — comfortably inside the
137
172
  * server's ~2-minute lease window (server/store.ts's LOCAL_TASK_LEASE_MS). */
138
173
  const HEARTBEAT_MS = 30_000;
174
+ /** How long to wait before retrying after something escapes the per-operation error handling. */
175
+ const LOOP_RECOVERY_DELAY_MS = 1000;
176
+ /** A quiet proof-of-life while no task is claimed. */
177
+ const IDLE_STATUS_MS = 5 * 60_000;
139
178
  /** The explicitly repo-aware task kinds, companion-side. Everything else falls through to a
140
179
  * connectionless runner: only our server creates tasks, their ModelTaskSpec is self-describing
141
180
  * (model, effort, maxTokens, system, stable/volatile text, optional schema), and graph reasoning
@@ -163,12 +202,13 @@ async function runTask(task, backend, anthropic, repoPath) {
163
202
  return backend === 'cli' ? runGenericTaskViaCli(task, repoPath) : runGenericTask(anthropic, task, repoPath);
164
203
  }
165
204
  async function runOneTask(anthropic, backend, client, repoPath, task) {
166
- console.log(`[${new Date().toISOString()}] claimed ${task.kind} task ${task.id} via ${backend} (budget $${task.maxBudgetUsd.toFixed(2)} — not yet enforced client-side, see README)`);
205
+ console.log(`[${new Date().toISOString()}] claimed ${task.kind} task ${task.id} via ${backend} (budget $${task.maxBudgetUsd.toFixed(2)})`);
167
206
  const heartbeat = setInterval(() => {
168
- void client.heartbeat(task.id).catch((err) => console.error(' heartbeat failed:', err instanceof Error ? err.message : err));
207
+ void client.heartbeat(task.id).catch((err) => console.error(' heartbeat warning:', err instanceof Error ? err.message : err));
169
208
  }, HEARTBEAT_MS);
170
209
  try {
171
210
  const { result, filesRead } = await runTask(task, backend, anthropic, repoPath);
211
+ clearInterval(heartbeat);
172
212
  await client.reportResult(task.id, result, filesRead);
173
213
  console.log(` done — reported result (${filesRead.length} file${filesRead.length === 1 ? '' : 's'} read)`);
174
214
  }
@@ -241,54 +281,76 @@ async function cmdRun() {
241
281
  console.log('Waiting for work — leave this running. Ctrl+C any time to stop.');
242
282
  let stopping = false;
243
283
  process.on('SIGINT', () => {
244
- if (stopping)
284
+ if (stopping) {
285
+ expectedExitReason = 'forced stop after a second Ctrl+C';
245
286
  process.exit(1); // second Ctrl+C: stop waiting, exit immediately
287
+ }
246
288
  console.log('\nStopping after the current poll — Ctrl+C again to force quit.');
289
+ expectedExitReason = 'stopped by Ctrl+C';
247
290
  stopping = true;
248
291
  });
292
+ let lastIdleStatusAt = Date.now();
249
293
  while (!stopping) {
250
- let claimedAny = false;
251
- for (const c of config.connections) {
252
- if (stopping)
253
- break;
254
- let claim;
255
- try {
256
- claim = await client.claimNext(c.connectionId);
294
+ try {
295
+ let claimedAny = false;
296
+ for (const c of config.connections) {
297
+ if (stopping)
298
+ break;
299
+ let claim;
300
+ try {
301
+ claim = await client.claimNext(c.connectionId);
302
+ }
303
+ catch (err) {
304
+ if (err instanceof CompanionHttpError && err.status === 401) {
305
+ console.error('Token rejected (revoked?) — stopping. Pair a new token and reconnect.');
306
+ expectedExitReason = 'the server rejected its token; pair a new token and reconnect';
307
+ return;
308
+ }
309
+ console.error(`Poll failed for ${c.connectionId}:`, err instanceof Error ? err.message : err);
310
+ continue;
311
+ }
312
+ if (!claim.task)
313
+ continue;
314
+ claimedAny = true;
315
+ await runOneTask(anthropic, c.backend, client, c.repoPath, claim.task);
257
316
  }
258
- catch (err) {
259
- if (err instanceof CompanionHttpError && err.status === 401) {
260
- console.error('Token rejected (revoked?) — stopping. Pair a new token and reconnect.');
261
- return;
317
+ if (!stopping && config.pollWorkspaceTasks) {
318
+ let claim;
319
+ try {
320
+ claim = await client.claimNext();
321
+ }
322
+ catch (err) {
323
+ if (err instanceof CompanionHttpError && err.status === 401) {
324
+ console.error('Token rejected (revoked?) — stopping. Pair a new token and reconnect.');
325
+ expectedExitReason = 'the server rejected its token; pair a new token and reconnect';
326
+ return;
327
+ }
328
+ console.error('Workspace-level poll failed:', err instanceof Error ? err.message : err);
329
+ claim = { task: null };
330
+ }
331
+ if (claim.task) {
332
+ claimedAny = true;
333
+ // Connection-less task kinds do not read a repo; cwd is a harmless placeholder.
334
+ await runOneTask(anthropic, workspaceBackend, client, process.cwd(), claim.task);
262
335
  }
263
- console.error(`Poll failed for ${c.connectionId}:`, err instanceof Error ? err.message : err);
264
- continue;
265
336
  }
266
- if (!claim.task)
267
- continue;
268
- claimedAny = true;
269
- await runOneTask(anthropic, c.backend, client, c.repoPath, claim.task);
270
- }
271
- if (!stopping && config.pollWorkspaceTasks) {
272
- let claim;
273
- try {
274
- claim = await client.claimNext();
337
+ if (claimedAny) {
338
+ lastIdleStatusAt = Date.now();
275
339
  }
276
- catch (err) {
277
- if (err instanceof CompanionHttpError && err.status === 401) {
278
- console.error('Token rejected (revoked?) — stopping. Pair a new token and reconnect.');
279
- return;
340
+ else if (!stopping) {
341
+ const now = Date.now();
342
+ if (now - lastIdleStatusAt >= IDLE_STATUS_MS) {
343
+ console.log(`[${new Date(now).toISOString()}] still watching — no work claimed.`);
344
+ lastIdleStatusAt = now;
280
345
  }
281
- console.error('Workspace-level poll failed:', err instanceof Error ? err.message : err);
282
- claim = { task: null };
283
- }
284
- if (claim.task) {
285
- claimedAny = true;
286
- // Connection-less task kinds do not read a repo; cwd is a harmless placeholder.
287
- await runOneTask(anthropic, workspaceBackend, client, process.cwd(), claim.task);
346
+ await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
288
347
  }
289
348
  }
290
- if (!claimedAny && !stopping)
291
- await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
349
+ catch (err) {
350
+ console.error('Unexpected run-loop error:', err instanceof Error ? err.message : err);
351
+ if (!stopping)
352
+ await new Promise((r) => setTimeout(r, LOOP_RECOVERY_DELAY_MS));
353
+ }
292
354
  }
293
355
  console.log('Stopped.');
294
356
  }
@@ -301,4 +363,12 @@ async function main() {
301
363
  console.log('Usage: product-console-companion <connect|run> [flags]');
302
364
  process.exitCode = cmd ? 1 : 0;
303
365
  }
304
- void main();
366
+ main()
367
+ .then(() => {
368
+ expectedExitReason ??= 'command completed';
369
+ })
370
+ .catch((reason) => {
371
+ const error = logFatal('FATAL top-level error', reason);
372
+ unexpectedExitReason = `top-level error: ${error.message}`;
373
+ process.exitCode = 1;
374
+ });
@@ -1,3 +1,8 @@
1
+ const WHOAMI_TIMEOUT_MS = 15_000;
2
+ const CLAIM_TIMEOUT_MS = 10_000;
3
+ const HEARTBEAT_TIMEOUT_MS = 10_000;
4
+ const RESULT_TIMEOUT_MS = 60_000;
5
+ const ERROR_REPORT_TIMEOUT_MS = 30_000;
1
6
  export class CompanionHttpError extends Error {
2
7
  status;
3
8
  constructor(status, message) {
@@ -12,10 +17,11 @@ export class CompanionClient {
12
17
  this.url = url;
13
18
  this.token = token;
14
19
  }
15
- async call(path, init) {
20
+ async call(path, timeoutMs, init) {
16
21
  const res = await fetch(`${this.url}${path}`, {
17
22
  ...init,
18
23
  headers: { ...init?.headers, Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
24
+ signal: AbortSignal.timeout(timeoutMs),
19
25
  });
20
26
  const body = (await res.json().catch(() => ({})));
21
27
  if (!res.ok)
@@ -23,22 +29,22 @@ export class CompanionClient {
23
29
  return body;
24
30
  }
25
31
  whoami() {
26
- return this.call('/api/companion/whoami');
32
+ return this.call('/api/companion/whoami', WHOAMI_TIMEOUT_MS);
27
33
  }
28
34
  claimNext(connectionId) {
29
- return this.call(connectionId ? `/api/companion/tasks/next?connectionId=${encodeURIComponent(connectionId)}` : '/api/companion/tasks/next');
35
+ return this.call(connectionId ? `/api/companion/tasks/next?connectionId=${encodeURIComponent(connectionId)}` : '/api/companion/tasks/next', CLAIM_TIMEOUT_MS);
30
36
  }
31
37
  heartbeat(taskId) {
32
- return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/heartbeat`, { method: 'POST' });
38
+ return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/heartbeat`, HEARTBEAT_TIMEOUT_MS, { method: 'POST' });
33
39
  }
34
40
  reportResult(taskId, result, filesRead) {
35
- return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/result`, {
41
+ return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/result`, RESULT_TIMEOUT_MS, {
36
42
  method: 'POST',
37
43
  body: JSON.stringify({ result, filesRead }),
38
44
  });
39
45
  }
40
46
  reportError(taskId, error) {
41
- return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/error`, {
47
+ return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/error`, ERROR_REPORT_TIMEOUT_MS, {
42
48
  method: 'POST',
43
49
  body: JSON.stringify({ error }),
44
50
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-console-companion",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Local-first BYOS companion for the Fynch Product Console — reads code and runs AI calls from your own machine instead of the hosted server.",
5
5
  "type": "module",
6
6
  "license": "MIT",