@commonlyai/cli 0.1.214 → 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 +1 -1
- package/src/commands/task.js +278 -0
- package/src/index.js +4 -0
- package/src/lib/adapters/claude.js +62 -6
- package/src/lib/first-output-watchdog.js +43 -4
- package/src/lib/task.js +234 -0
package/package.json
CHANGED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commonly task <subcommand>
|
|
3
|
+
*
|
|
4
|
+
* list — the pod's board, optionally narrowed by status, or to rows this identity holds
|
|
5
|
+
* create — a new row; --notes is a second request, because create does not take notes
|
|
6
|
+
* claim — take the lease on a row
|
|
7
|
+
* update — change a row's fields, and/or append a history note
|
|
8
|
+
* close — mark a row done, as its creator or its holder
|
|
9
|
+
*
|
|
10
|
+
* Identity and base URL come from ../lib/task.js. Every subcommand goes through
|
|
11
|
+
* connect() below, so none can grow its own fallback to the saved login. Flags are
|
|
12
|
+
* checked before the first request, so a refusal costs no network round trip.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { TOKEN_FILE_ENV } from '../lib/inbox.js';
|
|
16
|
+
import {
|
|
17
|
+
TASK_PRIORITIES,
|
|
18
|
+
TASK_STATUSES,
|
|
19
|
+
TASK_TOKEN_ENV,
|
|
20
|
+
TaskRefusal,
|
|
21
|
+
UPDATE_STATUSES,
|
|
22
|
+
checkPriority,
|
|
23
|
+
checkStatusFilter,
|
|
24
|
+
checkTaskId,
|
|
25
|
+
checkUpdateStatus,
|
|
26
|
+
createTaskClient,
|
|
27
|
+
describeFailure,
|
|
28
|
+
formatTaskLine,
|
|
29
|
+
requireText,
|
|
30
|
+
resolveTaskBaseUrl,
|
|
31
|
+
resolveTaskToken,
|
|
32
|
+
taskPath,
|
|
33
|
+
} from '../lib/task.js';
|
|
34
|
+
|
|
35
|
+
export const TASK_TOKEN_HELP = `Agent token file (or ${TOKEN_FILE_ENV}, or ${TASK_TOKEN_ENV})`;
|
|
36
|
+
|
|
37
|
+
const connect = (opts, env, fetchImpl) => createTaskClient({
|
|
38
|
+
baseUrl: resolveTaskBaseUrl(opts, env),
|
|
39
|
+
token: resolveTaskToken(opts, env),
|
|
40
|
+
fetchImpl,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const podOf = (opts) => requireText(opts.pod, '--pod');
|
|
44
|
+
|
|
45
|
+
const report = (err, error) => {
|
|
46
|
+
error(err instanceof TaskRefusal ? `refusing: ${err.message}` : describeFailure(err));
|
|
47
|
+
return 1;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// A row goes out as JSON for agents, and as the line a person scans otherwise.
|
|
51
|
+
const emitRow = (opts, log, task) => {
|
|
52
|
+
log(opts.json ? JSON.stringify(task, null, 2) : formatTaskLine(task));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const runTaskList = async (opts = {}, deps = {}) => {
|
|
56
|
+
const {
|
|
57
|
+
env = process.env, fetchImpl = fetch, log = console.log, error = console.error,
|
|
58
|
+
} = deps;
|
|
59
|
+
try {
|
|
60
|
+
const pod = podOf(opts);
|
|
61
|
+
const status = opts.status === undefined ? undefined : checkStatusFilter(opts.status);
|
|
62
|
+
const { tasks } = await connect(opts, env, fetchImpl).get(taskPath(pod), { status });
|
|
63
|
+
// The server marks each row claimedByMe for this identity, so --mine needs no second request.
|
|
64
|
+
const rows = opts.mine ? tasks.filter((task) => task.claimedByMe) : tasks;
|
|
65
|
+
if (opts.json) {
|
|
66
|
+
log(JSON.stringify(rows, null, 2));
|
|
67
|
+
} else if (rows.length === 0) {
|
|
68
|
+
log('(no tasks match)');
|
|
69
|
+
} else {
|
|
70
|
+
for (const task of rows) log(formatTaskLine(task));
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return report(err, error);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const runTaskCreate = async (opts = {}, deps = {}) => {
|
|
79
|
+
const {
|
|
80
|
+
env = process.env, fetchImpl = fetch, log = console.log, error = console.error,
|
|
81
|
+
} = deps;
|
|
82
|
+
try {
|
|
83
|
+
const pod = podOf(opts);
|
|
84
|
+
const body = { title: requireText(opts.title, '--title') };
|
|
85
|
+
if (opts.priority !== undefined) body.priority = checkPriority(opts.priority);
|
|
86
|
+
if (opts.assignee !== undefined) body.assignee = requireText(opts.assignee, '--assignee');
|
|
87
|
+
if (opts.dep !== undefined) body.dep = checkTaskId(opts.dep);
|
|
88
|
+
const notes = opts.notes === undefined ? undefined : requireText(opts.notes, '--notes');
|
|
89
|
+
const client = connect(opts, env, fetchImpl);
|
|
90
|
+
const { task } = await client.post(taskPath(pod), body);
|
|
91
|
+
if (notes === undefined) {
|
|
92
|
+
emitRow(opts, log, task);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
// The row exists once the create returns. If the notes fail, say so, so nobody
|
|
96
|
+
// retries the create and makes a twin.
|
|
97
|
+
try {
|
|
98
|
+
const { task: noted } = await client.patch(taskPath(pod, task.taskId), { notes });
|
|
99
|
+
emitRow(opts, log, noted);
|
|
100
|
+
return 0;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
error(`Created ${task.taskId}, but --notes did not save. ${describeFailure(err)}`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return report(err, error);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const runTaskClaim = async (taskArg, opts = {}, deps = {}) => {
|
|
111
|
+
const {
|
|
112
|
+
env = process.env, fetchImpl = fetch, log = console.log, error = console.error,
|
|
113
|
+
} = deps;
|
|
114
|
+
try {
|
|
115
|
+
const pod = podOf(opts);
|
|
116
|
+
const taskId = checkTaskId(taskArg);
|
|
117
|
+
const body = opts.continueAnyway ? { continueAnyway: true } : {};
|
|
118
|
+
const { task } = await connect(opts, env, fetchImpl).post(`${taskPath(pod, taskId)}/claim`, body);
|
|
119
|
+
emitRow(opts, log, task);
|
|
120
|
+
return 0;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return report(err, error);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const runTaskUpdate = async (taskArg, opts = {}, deps = {}) => {
|
|
127
|
+
const {
|
|
128
|
+
env = process.env, fetchImpl = fetch, log = console.log, error = console.error,
|
|
129
|
+
} = deps;
|
|
130
|
+
try {
|
|
131
|
+
const pod = podOf(opts);
|
|
132
|
+
const taskId = checkTaskId(taskArg);
|
|
133
|
+
const fields = {};
|
|
134
|
+
if (opts.status !== undefined) fields.status = checkUpdateStatus(opts.status);
|
|
135
|
+
// An empty value unassigns. The server stores that as null, not as '', so the row
|
|
136
|
+
// reads as unassigned to the router.
|
|
137
|
+
if (opts.assignee !== undefined) fields.assignee = String(opts.assignee).trim();
|
|
138
|
+
if (opts.title !== undefined) fields.title = requireText(opts.title, '--title');
|
|
139
|
+
if (opts.priority !== undefined) fields.priority = checkPriority(opts.priority);
|
|
140
|
+
// "none" clears the dependency. Sending '' would store a dependency that names nothing.
|
|
141
|
+
if (opts.dep !== undefined) fields.dep = opts.dep === 'none' ? null : checkTaskId(opts.dep);
|
|
142
|
+
const note = opts.note === undefined ? undefined : requireText(opts.note, '--note');
|
|
143
|
+
if (Object.keys(fields).length === 0 && note === undefined) {
|
|
144
|
+
throw new TaskRefusal('nothing to change. Pass --status, --assignee, --title, --priority, --dep or --note.');
|
|
145
|
+
}
|
|
146
|
+
const client = connect(opts, env, fetchImpl);
|
|
147
|
+
const path = taskPath(pod, taskId);
|
|
148
|
+
let task = null;
|
|
149
|
+
if (Object.keys(fields).length > 0) ({ task } = await client.patch(path, fields));
|
|
150
|
+
if (note !== undefined) {
|
|
151
|
+
try {
|
|
152
|
+
const body = opts.continueAnyway ? { text: note, continueAnyway: true } : { text: note };
|
|
153
|
+
({ task } = await client.post(`${path}/updates`, body));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// With no field change before it, a failed note is the whole failure.
|
|
156
|
+
if (task === null) throw err;
|
|
157
|
+
error(`Updated ${taskId}, but --note did not post. ${describeFailure(err)}`);
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
emitRow(opts, log, task);
|
|
162
|
+
return 0;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return report(err, error);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const runTaskClose = async (taskArg, opts = {}, deps = {}) => {
|
|
169
|
+
const {
|
|
170
|
+
env = process.env, fetchImpl = fetch, log = console.log, error = console.error,
|
|
171
|
+
} = deps;
|
|
172
|
+
try {
|
|
173
|
+
const pod = podOf(opts);
|
|
174
|
+
const taskId = checkTaskId(taskArg);
|
|
175
|
+
const prUrl = opts.pr === undefined ? undefined : requireText(opts.pr, '--pr');
|
|
176
|
+
// No notes key here. /complete replaces the row's notes whenever one is sent.
|
|
177
|
+
const body = prUrl === undefined ? {} : { prUrl };
|
|
178
|
+
const { task } = await connect(opts, env, fetchImpl).post(`${taskPath(pod, taskId)}/complete`, body);
|
|
179
|
+
emitRow(opts, log, task);
|
|
180
|
+
return 0;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return report(err, error);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const registerTask = (program) => {
|
|
187
|
+
const task = program
|
|
188
|
+
.command('task')
|
|
189
|
+
.description('Read and write the task board as an agent (list, create, claim, update, close)');
|
|
190
|
+
|
|
191
|
+
task.addHelpText('after', `
|
|
192
|
+
The board is read and written as the identity in a token file, and nowhere else:
|
|
193
|
+
|
|
194
|
+
$ commonly task list --pod <podId> --token-file <path>
|
|
195
|
+
$ COMMONLY_AGENT_TOKEN=<token> commonly task claim TASK-123 --pod <podId>
|
|
196
|
+
|
|
197
|
+
The identity is --token-file, then COMMONLY_TOKEN_FILE, then COMMONLY_AGENT_TOKEN.
|
|
198
|
+
The saved login and COMMONLY_TOKEN are never read: a write made under an
|
|
199
|
+
operator's login lands on the row under that operator's name.
|
|
200
|
+
|
|
201
|
+
Each verb does what it says. \`claim\` takes the lease. \`close\` stamps the
|
|
202
|
+
completion time and the PR. \`update --status\` sets only pending or blocked, so it
|
|
203
|
+
cannot claim or close a row. \`update --note\` appends a history line. The row's
|
|
204
|
+
notes are set by \`create --notes\` and are not editable here.
|
|
205
|
+
|
|
206
|
+
A claim or a note can refuse with unseen messages in the pod. Read them and retry,
|
|
207
|
+
or pass --continue-anyway to go ahead without reading them.
|
|
208
|
+
|
|
209
|
+
Examples:
|
|
210
|
+
$ commonly task list --pod <podId> --status pending,claimed
|
|
211
|
+
$ commonly task list --pod <podId> --mine --json
|
|
212
|
+
$ commonly task create --pod <podId> --title "Fix the board" --priority P1 --notes "Repro in staging"
|
|
213
|
+
$ commonly task claim TASK-123 --pod <podId>
|
|
214
|
+
$ commonly task update TASK-123 --pod <podId> --note "PR is up"
|
|
215
|
+
$ commonly task close TASK-123 --pod <podId> --pr https://github.com/Team-Commonly/commonly/pull/<n>
|
|
216
|
+
`);
|
|
217
|
+
|
|
218
|
+
task
|
|
219
|
+
.command('list')
|
|
220
|
+
.description("List a pod's task board")
|
|
221
|
+
.requiredOption('--pod <podId>', 'Pod whose board to read')
|
|
222
|
+
.option('--status <list>', `Only these statuses, comma-separated (${TASK_STATUSES.join('|')})`)
|
|
223
|
+
.option('--mine', 'Only rows this identity holds a claim on')
|
|
224
|
+
.option('--token-file <path>', TASK_TOKEN_HELP)
|
|
225
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
226
|
+
.option('--json', 'Emit the rows as JSON')
|
|
227
|
+
.action(async (opts) => { process.exit(await runTaskList(opts)); });
|
|
228
|
+
|
|
229
|
+
task
|
|
230
|
+
.command('create')
|
|
231
|
+
.description('Create a row, then set its --notes (create does not take notes)')
|
|
232
|
+
.requiredOption('--pod <podId>', 'Pod to create the row in')
|
|
233
|
+
.requiredOption('--title <text>', 'Row title')
|
|
234
|
+
.option('--notes <text>', 'Notes to set once the row exists')
|
|
235
|
+
.option('--priority <P>', `${TASK_PRIORITIES.join('|')} (default P2)`)
|
|
236
|
+
.option('--assignee <name>', 'Assign the row at creation')
|
|
237
|
+
.option('--dep <TASK-n>', 'Row this one waits on')
|
|
238
|
+
.option('--token-file <path>', TASK_TOKEN_HELP)
|
|
239
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
240
|
+
.option('--json', 'Emit the row as JSON')
|
|
241
|
+
.action(async (opts) => { process.exit(await runTaskCreate(opts)); });
|
|
242
|
+
|
|
243
|
+
task
|
|
244
|
+
.command('claim <taskId>')
|
|
245
|
+
.description('Take the lease on a row (TASK-n)')
|
|
246
|
+
.requiredOption('--pod <podId>', 'Pod the row is in')
|
|
247
|
+
.option('--continue-anyway', 'Claim even with unseen messages in the pod')
|
|
248
|
+
.option('--token-file <path>', TASK_TOKEN_HELP)
|
|
249
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
250
|
+
.option('--json', 'Emit the row as JSON')
|
|
251
|
+
.action(async (taskId, opts) => { process.exit(await runTaskClaim(taskId, opts)); });
|
|
252
|
+
|
|
253
|
+
task
|
|
254
|
+
.command('update <taskId>')
|
|
255
|
+
.description("Change a row's fields, and/or append a history note")
|
|
256
|
+
.requiredOption('--pod <podId>', 'Pod the row is in')
|
|
257
|
+
.option('--status <status>', `${UPDATE_STATUSES.join('|')} (claim and close have their own commands)`)
|
|
258
|
+
.option('--assignee <name>', 'Reassign the row; an empty value unassigns it')
|
|
259
|
+
.option('--title <text>', 'New title')
|
|
260
|
+
.option('--priority <P>', TASK_PRIORITIES.join('|'))
|
|
261
|
+
.option('--dep <TASK-n>', 'Row this one waits on; "none" clears it')
|
|
262
|
+
.option('--note <text>', 'Append a history note')
|
|
263
|
+
.option('--continue-anyway', 'Post the note even with unseen messages in the pod')
|
|
264
|
+
.option('--token-file <path>', TASK_TOKEN_HELP)
|
|
265
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
266
|
+
.option('--json', 'Emit the row as JSON')
|
|
267
|
+
.action(async (taskId, opts) => { process.exit(await runTaskUpdate(taskId, opts)); });
|
|
268
|
+
|
|
269
|
+
task
|
|
270
|
+
.command('close <taskId>')
|
|
271
|
+
.description('Mark a row done, as its creator or its holder')
|
|
272
|
+
.requiredOption('--pod <podId>', 'Pod the row is in')
|
|
273
|
+
.option('--pr <url>', 'Pull request that delivered the row')
|
|
274
|
+
.option('--token-file <path>', TASK_TOKEN_HELP)
|
|
275
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
276
|
+
.option('--json', 'Emit the row as JSON')
|
|
277
|
+
.action(async (taskId, opts) => { process.exit(await runTaskClose(taskId, opts)); });
|
|
278
|
+
};
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { registerDev } from './commands/dev.js';
|
|
|
23
23
|
import { registerVerify } from './commands/verify.js';
|
|
24
24
|
import { registerDoctor } from './commands/doctor.js';
|
|
25
25
|
import { registerMcp } from './commands/mcp.js';
|
|
26
|
+
import { registerTask } from './commands/task.js';
|
|
26
27
|
|
|
27
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
29
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
|
|
@@ -60,6 +61,9 @@ registerVerify(program);
|
|
|
60
61
|
registerDoctor(program);
|
|
61
62
|
registerMcp(program);
|
|
62
63
|
|
|
64
|
+
// The task board as an agent: list, create, claim, update, close (TASK-703)
|
|
65
|
+
registerTask(program);
|
|
66
|
+
|
|
63
67
|
// Each subcommand owns its own `--instance <url>` flag; a program-level
|
|
64
68
|
// duplicate shadowed the subcommand value on commander v12, causing
|
|
65
69
|
// `commonly login --instance …` to silently fall back to the default URL.
|
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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;
|
package/src/lib/task.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The task board from the CLI: identity, the request layer, and one-line
|
|
3
|
+
* rendering. The routes are the ones the commonly_* task tools call
|
|
4
|
+
* (backend/routes/tasksApi.ts), so the CLI and the MCP cannot disagree about
|
|
5
|
+
* what a write means.
|
|
6
|
+
*
|
|
7
|
+
* Identity is an explicit token and nothing else: --token-file, then
|
|
8
|
+
* COMMONLY_TOKEN_FILE, then COMMONLY_AGENT_TOKEN. The saved login and
|
|
9
|
+
* COMMONLY_TOKEN are never read. A write made under an operator's saved login
|
|
10
|
+
* lands on the row under that operator's name.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'fs';
|
|
14
|
+
import { DEFAULT_URL, resolveInstance } from './config.js';
|
|
15
|
+
import { extractToken, resolveTokenFilePath, TOKEN_FILE_ENV } from './inbox.js';
|
|
16
|
+
|
|
17
|
+
export const API_URL_ENV = 'COMMONLY_API_URL';
|
|
18
|
+
export const TASK_TOKEN_ENV = 'COMMONLY_AGENT_TOKEN';
|
|
19
|
+
export const TASK_STATUSES = ['pending', 'claimed', 'done', 'blocked'];
|
|
20
|
+
// `update` writes only these. A claim writes the lease, which /claim owns, and a
|
|
21
|
+
// close stamps completedAt and the PR, which PATCH does not.
|
|
22
|
+
export const UPDATE_STATUSES = ['pending', 'blocked'];
|
|
23
|
+
export const TASK_PRIORITIES = ['P0', 'P1', 'P2', 'P3'];
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
/** A refusal made before any request: a bad flag, or no identity to send. */
|
|
27
|
+
export class TaskRefusal extends Error {}
|
|
28
|
+
|
|
29
|
+
/** A failed call. `status` is 0 when the server was never reached. */
|
|
30
|
+
export class TaskRequestError extends Error {
|
|
31
|
+
constructor(message, { status = 0, code = null, body = null } = {}) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'TaskRequestError';
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.body = body;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const requireText = (value, flag) => {
|
|
41
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
42
|
+
if (!text) throw new TaskRefusal(`${flag} needs a value.`);
|
|
43
|
+
return text;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const checkPriority = (value) => {
|
|
47
|
+
const priority = String(value).trim().toUpperCase();
|
|
48
|
+
if (!TASK_PRIORITIES.includes(priority)) {
|
|
49
|
+
throw new TaskRefusal(`--priority must be ${TASK_PRIORITIES.join(', ')} (got '${value}').`);
|
|
50
|
+
}
|
|
51
|
+
return priority;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const checkStatusFilter = (value) => {
|
|
55
|
+
const statuses = String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
if (statuses.length === 0 || statuses.some((s) => !TASK_STATUSES.includes(s))) {
|
|
57
|
+
throw new TaskRefusal(`--status takes ${TASK_STATUSES.join(', ')}, comma-separated (got '${value}').`);
|
|
58
|
+
}
|
|
59
|
+
return statuses.join(',');
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const checkUpdateStatus = (value) => {
|
|
63
|
+
const status = String(value).trim().toLowerCase();
|
|
64
|
+
if (status === 'claimed') {
|
|
65
|
+
throw new TaskRefusal('--status claimed is refused: `commonly task claim` writes the lease, and a bare status does not.');
|
|
66
|
+
}
|
|
67
|
+
if (status === 'done') {
|
|
68
|
+
throw new TaskRefusal('--status done is refused: `commonly task close` stamps completedAt and the PR, and PATCH does not.');
|
|
69
|
+
}
|
|
70
|
+
if (!UPDATE_STATUSES.includes(status)) {
|
|
71
|
+
throw new TaskRefusal(`--status must be ${UPDATE_STATUSES.join(' or ')} (got '${value}').`);
|
|
72
|
+
}
|
|
73
|
+
return status;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** TASK-669, task-669 and 669 name one row. Anything else is refused before a request. */
|
|
77
|
+
export const checkTaskId = (value) => {
|
|
78
|
+
const match = String(value ?? '').trim().match(/^(?:task-)?(\d+)$/i);
|
|
79
|
+
if (!match) throw new TaskRefusal(`task id must look like TASK-123 (got '${value ?? ''}').`);
|
|
80
|
+
return `TASK-${String(Number(match[1])).padStart(3, '0')}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the token. A token file that exists but holds nothing is refused by
|
|
85
|
+
* its path. The message never suggests `commonly login`, because pointing back
|
|
86
|
+
* at the saved login is the thing this rule exists to prevent.
|
|
87
|
+
*/
|
|
88
|
+
export const resolveTaskToken = (opts = {}, env = process.env) => {
|
|
89
|
+
const tokenFile = resolveTokenFilePath(opts, env);
|
|
90
|
+
if (tokenFile) {
|
|
91
|
+
let raw;
|
|
92
|
+
try {
|
|
93
|
+
raw = readFileSync(tokenFile, 'utf8');
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new TaskRefusal(`cannot read the token file ${tokenFile} (${err?.code || err?.message}).`);
|
|
96
|
+
}
|
|
97
|
+
const token = extractToken(raw);
|
|
98
|
+
if (!token) throw new TaskRefusal(`no token in ${tokenFile}. Expected a raw token, or JSON with a token field.`);
|
|
99
|
+
return token;
|
|
100
|
+
}
|
|
101
|
+
const fromEnv = extractToken(env[TASK_TOKEN_ENV]);
|
|
102
|
+
if (fromEnv) return fromEnv;
|
|
103
|
+
throw new TaskRefusal(`no token. Pass --token-file <path>, or set ${TOKEN_FILE_ENV} or ${TASK_TOKEN_ENV}.`);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* --instance takes a URL or a saved key, and an unknown key is refused. Falling
|
|
108
|
+
* back to the default host would send this token to an instance it was not
|
|
109
|
+
* minted on.
|
|
110
|
+
*/
|
|
111
|
+
export const resolveTaskBaseUrl = (opts = {}, env = process.env) => {
|
|
112
|
+
if (opts.instance) {
|
|
113
|
+
const inst = resolveInstance(opts.instance);
|
|
114
|
+
if (!inst?.url) {
|
|
115
|
+
throw new TaskRefusal(`unknown instance '${opts.instance}'. Pass its URL, or a saved instance key.`);
|
|
116
|
+
}
|
|
117
|
+
return inst.url.replace(/\/$/, '');
|
|
118
|
+
}
|
|
119
|
+
const fromEnv = String(env[API_URL_ENV] || '').trim().replace(/\/$/, '');
|
|
120
|
+
return fromEnv || DEFAULT_URL;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** `/api/v1/tasks/<pod>` for the board, and `…/<taskId>` for one row. */
|
|
124
|
+
export const taskPath = (podId, taskId = null) => [
|
|
125
|
+
'/api/v1/tasks',
|
|
126
|
+
encodeURIComponent(podId),
|
|
127
|
+
...(taskId === null ? [] : [encodeURIComponent(taskId)]),
|
|
128
|
+
].join('/');
|
|
129
|
+
|
|
130
|
+
const messageFor = (status, body, text) => {
|
|
131
|
+
if (body && typeof body.error === 'string' && body.error) return body.error;
|
|
132
|
+
if (body && typeof body.message === 'string' && body.message) return body.message;
|
|
133
|
+
const trimmed = String(text || '').trim();
|
|
134
|
+
if (!trimmed) return `HTTP ${status}`;
|
|
135
|
+
if (/^<(?:!doctype|html)/i.test(trimmed)) {
|
|
136
|
+
const title = trimmed.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
|
|
137
|
+
return title ? title.replace(/\s+/g, ' ').trim() : 'HTML error page';
|
|
138
|
+
}
|
|
139
|
+
return trimmed.replace(/\s+/g, ' ').slice(0, 200);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One attempt per call. The writes here are POSTs, and a retried create or note
|
|
144
|
+
* lands twice, so a failure is reported and the caller decides what to do next.
|
|
145
|
+
*/
|
|
146
|
+
export const createTaskClient = ({ baseUrl, token, fetchImpl = fetch }) => {
|
|
147
|
+
const send = async (method, path, body) => {
|
|
148
|
+
let res;
|
|
149
|
+
try {
|
|
150
|
+
res = await fetchImpl(`${baseUrl}${path}`, {
|
|
151
|
+
method,
|
|
152
|
+
headers: {
|
|
153
|
+
Authorization: `Bearer ${token}`,
|
|
154
|
+
'Content-Type': 'application/json',
|
|
155
|
+
},
|
|
156
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
157
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
158
|
+
});
|
|
159
|
+
} catch (err) {
|
|
160
|
+
throw new TaskRequestError(`could not reach ${baseUrl} (${err?.message || err}).`);
|
|
161
|
+
}
|
|
162
|
+
const text = await res.text();
|
|
163
|
+
let parsed = null;
|
|
164
|
+
try {
|
|
165
|
+
parsed = text ? JSON.parse(text) : null;
|
|
166
|
+
} catch {
|
|
167
|
+
parsed = null;
|
|
168
|
+
}
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
throw new TaskRequestError(messageFor(res.status, parsed, text), {
|
|
171
|
+
status: res.status,
|
|
172
|
+
code: parsed?.code || null,
|
|
173
|
+
body: parsed,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return parsed;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const get = (path, params = {}) => {
|
|
180
|
+
const query = new URLSearchParams(
|
|
181
|
+
Object.entries(params).filter(([, value]) => value !== undefined && value !== ''),
|
|
182
|
+
).toString();
|
|
183
|
+
return send('GET', query ? `${path}?${query}` : path);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
get,
|
|
188
|
+
post: (path, body = {}) => send('POST', path, body),
|
|
189
|
+
patch: (path, body = {}) => send('PATCH', path, body),
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const clockOf = (iso) => {
|
|
194
|
+
if (!iso) return null;
|
|
195
|
+
const at = new Date(iso);
|
|
196
|
+
return Number.isNaN(at.getTime()) ? null : `${at.toISOString().slice(11, 16)}Z`;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** One row, as a human reads it. A claimed row also says whether its lease holds. */
|
|
200
|
+
export const formatTaskLine = (task) => {
|
|
201
|
+
const line = [task.taskId, task.status, task.priority || '-', task.assignee || '-', task.title].join(' ');
|
|
202
|
+
if (task.status !== 'claimed') return line;
|
|
203
|
+
const at = clockOf(task.claimExpiresAt);
|
|
204
|
+
if (!at) return line;
|
|
205
|
+
const lapsed = new Date(task.claimExpiresAt).getTime() < Date.now();
|
|
206
|
+
return `${line} (${lapsed ? 'lease lapsed' : 'held until'} ${at})`;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* One line for a failed call. The unseen-messages hold and a lease conflict get
|
|
211
|
+
* their own wording, because a seat acts on each differently. Everything else is
|
|
212
|
+
* the server's own text.
|
|
213
|
+
*/
|
|
214
|
+
export const describeFailure = (err) => {
|
|
215
|
+
if (!(err instanceof TaskRequestError)) return `Failed: ${err?.message || err}`;
|
|
216
|
+
if (err.status === 0) return `Failed: ${err.message}`;
|
|
217
|
+
const body = err.body || {};
|
|
218
|
+
let line;
|
|
219
|
+
if (err.code === 'unseen_messages') {
|
|
220
|
+
const count = Number(body.unseenCount) || 0;
|
|
221
|
+
line = `Failed (HTTP ${err.status}): ${count} unseen message${count === 1 ? '' : 's'} in this pod. `
|
|
222
|
+
+ 'Read them and retry, or pass --continue-anyway to go ahead.';
|
|
223
|
+
} else {
|
|
224
|
+
const detail = [
|
|
225
|
+
body.claimedBy ? `held by ${body.claimedBy}` : null,
|
|
226
|
+
clockOf(body.claimExpiresAt) ? `lease until ${clockOf(body.claimExpiresAt)}` : null,
|
|
227
|
+
].filter(Boolean).join(', ');
|
|
228
|
+
line = `Failed (HTTP ${err.status}): ${err.message}${detail ? ` (${detail})` : ''}`;
|
|
229
|
+
if (err.status === 401) {
|
|
230
|
+
line += '. Check the agent token: --token-file, COMMONLY_TOKEN_FILE or COMMONLY_AGENT_TOKEN.';
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return line.replace(/\s+/g, ' ');
|
|
234
|
+
};
|