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 +13 -2
- package/dist/cli.js +109 -39
- package/dist/http-client.js +12 -6
- package/package.json +1 -1
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
|
-
- **
|
|
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)}
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 (
|
|
267
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/http-client.js
CHANGED
|
@@ -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.
|
|
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",
|