@commonlyai/cli 0.1.215 → 0.1.216

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.215",
3
+ "version": "0.1.216",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -494,10 +494,65 @@ const publicSkillPromptArgs = async (skillSources) => {
494
494
 
495
495
  // `ownGroup` is set only under the run supervisor, which kills the child's
496
496
  // whole group; unsupervised, the spawn options are exactly what they were.
497
+ // Stream-json so each model event is a sign of life for the watchdog. The
498
+ // partial messages put thinking deltas on the stream too, so a long think is
499
+ // visible while it runs. A hung provider writes only `system` retry events,
500
+ // and the watchdog does not count those.
501
+ const STREAM_ARGS = ['--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
502
+
503
+ const parseEvent = (line) => {
504
+ try {
505
+ const event = JSON.parse(line);
506
+ return event && typeof event === 'object' ? event : null;
507
+ } catch {
508
+ return null;
509
+ }
510
+ };
511
+
512
+ // The reply is the `result` event's text. A turn that reaches no result event
513
+ // (killed mid-stream, exit 0) gets the text of its assistant events, never the
514
+ // raw NDJSON. Output with no JSON lines at all is plain text and returned as it
515
+ // arrived. A result flagged `is_error` is a failed turn, whatever its exit code,
516
+ // so it is raised here, outside the parse guard, and never returned as a reply.
517
+ export const claudeResultText = (stdout, { credentials = [] } = {}) => {
518
+ const text = String(stdout || '');
519
+ const events = text.split('\n').map(parseEvent);
520
+ if (!events.some(Boolean)) return text.trim();
521
+ for (let i = events.length - 1; i >= 0; i -= 1) {
522
+ if (events[i]?.type !== 'result') continue;
523
+ const reply = String(events[i].result ?? '').trim();
524
+ if (events[i].is_error === true) throw adapterFailure('claude', reply, { credentials });
525
+ return reply;
526
+ }
527
+ return events
528
+ .filter((event) => event?.type === 'assistant')
529
+ .flatMap((event) => event.message?.content || [])
530
+ .filter((block) => block?.type === 'text')
531
+ .map((block) => block.text)
532
+ .join('')
533
+ .trim();
534
+ };
535
+
536
+ // The failure tail for a non-zero exit. Stream events are mostly system init
537
+ // and status lines, which would push the terminal error past the 2000-char cut,
538
+ // so keep the result text, or the lines that are not system or stream events.
539
+ export const claudeFailureTail = (stdout) => {
540
+ const text = String(stdout || '');
541
+ const lines = text.split('\n');
542
+ const events = lines.map(parseEvent);
543
+ if (!events.some(Boolean)) return text.trim();
544
+ const result = [...events].reverse().find((event) => event?.type === 'result');
545
+ if (String(result?.result ?? '').trim()) return String(result.result).trim();
546
+ return lines
547
+ .filter((_, i) => !['system', 'stream_event'].includes(events[i]?.type))
548
+ .join('\n')
549
+ .trim();
550
+ };
551
+
497
552
  const runClaude = ({
498
553
  cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
499
554
  transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
500
- processTable = readProcessTable, survivorIo,
555
+ processTable = readProcessTable, survivorIo, modelStream = true,
501
556
  }) => new Promise((resolve, reject) => {
502
557
  const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
503
558
  cwd,
@@ -529,6 +584,7 @@ const runClaude = ({
529
584
  proc,
530
585
  windowMs: firstOutputMs,
531
586
  transcriptPath,
587
+ modelStream,
532
588
  lockProbe,
533
589
  onStall: (reason) => {
534
590
  stalled = spawnStalledError({ label: 'claude', windowMs: firstOutputMs, reason });
@@ -584,7 +640,7 @@ const runClaude = ({
584
640
  // reduced to one named field. The status, when the tail names one, is
585
641
  // attached as `error.status` so a refusal classifies by field rather than
586
642
  // by regex.
587
- const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join(' | ');
643
+ const detail = [stderr.trim(), claudeFailureTail(stdout)].filter(Boolean).join(' | ');
588
644
  return reject(adapterFailure('claude', detail, {
589
645
  credentials,
590
646
  exitCode: code,
@@ -1161,7 +1217,7 @@ export default {
1161
1217
  ...(seatModel ? ['--model', seatModel] : []),
1162
1218
  ...(ctx.environment?.effort ? ['--effort', String(ctx.environment.effort)] : []),
1163
1219
  ];
1164
- const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId, ...modelArgs];
1220
+ const baseArgs = ['-p', fullPrompt, ...STREAM_ARGS, sessionFlag, sessionId, ...modelArgs];
1165
1221
 
1166
1222
  let mountedSkills = [];
1167
1223
  let skillMount = { mounted: [], conflicted: [] };
@@ -1274,7 +1330,7 @@ export default {
1274
1330
  const contextTokens = claudeContextTokens({
1275
1331
  cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
1276
1332
  });
1277
- return { text: stdout.trim(), newSessionId: sessionId, contextTokens };
1333
+ return { text: claudeResultText(stdout, { credentials: spawnCredentials(spawnCtx) }), newSessionId: sessionId, contextTokens };
1278
1334
  } catch (err) {
1279
1335
  // A public seat's refusal is not a session-store failure, whatever its
1280
1336
  // message happens to contain: it must reach the run loop, never the
@@ -1295,7 +1351,7 @@ export default {
1295
1351
  memoryWithheld: ctx.memoryWithheld === true,
1296
1352
  });
1297
1353
  const retryBase = ['-p', freshPrompt,
1298
- '--output-format', 'text', '--session-id', freshId, ...modelArgs];
1354
+ ...STREAM_ARGS, '--session-id', freshId, ...modelArgs];
1299
1355
  const retryCtx = {
1300
1356
  ...ctx,
1301
1357
  mcpConfigPath: mcpConfig?.file || null,
@@ -1337,7 +1393,7 @@ export default {
1337
1393
  cwd: physicalPath(ctx.cwd), sessionId: freshId,
1338
1394
  configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
1339
1395
  });
1340
- return { text: stdout.trim(), newSessionId: freshId, contextTokens };
1396
+ return { text: claudeResultText(stdout, { credentials: spawnCredentials(retryCtx) }), newSessionId: freshId, contextTokens };
1341
1397
  }
1342
1398
  throw err;
1343
1399
  } finally {
@@ -16,6 +16,9 @@
16
16
  * A spawn that does neither inside the window is killed with a reason. When
17
17
  * the transcript is missing altogether, the path may simply be wrong, so the
18
18
  * spawn is killed only if the console is also locked.
19
+ *
20
+ * That is the rule for a plain-text spawn. A stream-json spawn (TASK-710) counts
21
+ * only model events on stdout; see `modelStream` on watchFirstOutput.
19
22
  */
20
23
  import { realpathSync, statSync } from 'fs';
21
24
  import { readProcess } from '@commonlyai/seats/process-group';
@@ -99,15 +102,35 @@ const sizeOf = (file) => {
99
102
  }
100
103
  };
101
104
 
105
+ // A stream-json line is model progress when it is an event other than `system`.
106
+ // The `system` events are `init` (written before any model call) and
107
+ // `api_retry` (a provider failing and retrying). Counting either would keep a
108
+ // hung provider looking alive until the run timeout.
109
+ export const isModelProgressLine = (line) => {
110
+ try {
111
+ const event = JSON.parse(line);
112
+ return !!event && typeof event === 'object' && event.type !== 'system';
113
+ } catch {
114
+ return false;
115
+ }
116
+ };
117
+
102
118
  /**
103
119
  * Watch `proc` for its first sign of life. Returns `{ stop }`; call it when the
104
120
  * process closes. `onStall(reason)` fires at most once, after the caller should
105
121
  * kill the process.
122
+ *
123
+ * With `modelStream`, the spawn writes stream-json events. Only model output
124
+ * counts as life there. A transcript row does not, because claude writes its
125
+ * first row about a second after spawn, before any model reply. Stderr does
126
+ * not either: claude prints warnings at spawn (an unrecognised model, a
127
+ * connector notice) whatever the provider does next.
106
128
  */
107
129
  export const watchFirstOutput = ({
108
130
  proc,
109
131
  windowMs,
110
132
  transcriptPath = null,
133
+ modelStream = false,
111
134
  onStall,
112
135
  lockProbe = consoleLocked,
113
136
  setTimeoutImpl = setTimeout,
@@ -116,16 +139,32 @@ export const watchFirstOutput = ({
116
139
  if (!windowMs) return { stop: () => {} };
117
140
  let started = false;
118
141
  let stopped = false;
142
+ let partial = '';
119
143
  const markStarted = () => { started = true; };
120
- proc.stdout?.on('data', markStarted);
121
- proc.stderr?.on('data', markStarted);
144
+ if (modelStream) {
145
+ proc.stdout?.on('data', (chunk) => {
146
+ const lines = (partial + chunk.toString()).split('\n');
147
+ partial = lines.pop();
148
+ if (lines.some(isModelProgressLine)) markStarted();
149
+ });
150
+ } else {
151
+ proc.stdout?.on('data', markStarted);
152
+ proc.stderr?.on('data', markStarted);
153
+ }
122
154
  // A missing file counts as empty, so creating an empty one is not growth.
123
155
  const baseline = Math.max(0, sizeOf(transcriptPath));
124
156
  const timer = setTimeoutImpl(async () => {
125
157
  const size = sizeOf(transcriptPath);
126
- if (started || size > baseline) return;
158
+ if (started || (!modelStream && size > baseline)) return;
127
159
  const locked = await lockProbe().catch(() => null);
128
- if (stopped) return;
160
+ // Output can arrive while the probe runs (ps or ioreg may take a second).
161
+ if (stopped || started) return;
162
+ if (modelStream) {
163
+ // The model signal does not read the transcript, so a missing path cannot
164
+ // hide a hung provider here.
165
+ onStall(locked ? 'console locked' : 'stuck: no model output inside the window');
166
+ return;
167
+ }
129
168
  // No transcript where one was expected may only mean the path is wrong, so
130
169
  // that alone never kills a turn: it takes a locked console as well.
131
170
  if (size < 0 && locked !== true) return;