@ze-norm/cli 0.12.0 → 0.13.0
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/dist/commands/interactive-agent.d.ts +92 -0
- package/dist/commands/interactive-agent.d.ts.map +1 -0
- package/dist/commands/interactive-agent.js +120 -0
- package/dist/commands/pull.d.ts.map +1 -1
- package/dist/commands/pull.js +23 -2
- package/dist/commands/work-render.d.ts +22 -0
- package/dist/commands/work-render.d.ts.map +1 -1
- package/dist/commands/work-render.js +33 -0
- package/dist/commands/work.d.ts +117 -3
- package/dist/commands/work.d.ts.map +1 -1
- package/dist/commands/work.js +391 -41
- package/dist/config/schema.d.ts +10 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +6 -0
- package/dist/util/markdown.d.ts +16 -0
- package/dist/util/markdown.d.ts.map +1 -1
- package/dist/util/markdown.js +34 -0
- package/package.json +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ChildProcess, SpawnOptions } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Opt-in coding-agent mode for local `zenorm work` runs.
|
|
4
|
+
*
|
|
5
|
+
* When a user opts in (`--interactive` or `.zenorm.json` `interactive: true`),
|
|
6
|
+
* the daemon does NOT run the agent headlessly. Instead it launches the active
|
|
7
|
+
* coding agent — Claude Code by default, or Codex when the user runs with
|
|
8
|
+
* `--agent codex` — as a *live interactive session*, seeded with the
|
|
9
|
+
* `/zenorm <specId>` handoff. The agent owns the terminal directly (inherited
|
|
10
|
+
* stdio), so the operator works with it interactively until they close it; when
|
|
11
|
+
* the agent exits, the daemon releases the worker and moves on to the next task.
|
|
12
|
+
*
|
|
13
|
+
* Scope is deliberately narrow (single local operator): the agent is attached
|
|
14
|
+
* to *this* process's own stdio. There is no network listener, no second
|
|
15
|
+
* connection, nothing remote — `assertLocalAgentOptions` enforces those
|
|
16
|
+
* boundaries; this module just launches the local interactive agent.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The minimal surface we need from a TTY check — injectable so tests can drive
|
|
20
|
+
* both the "usable TTY" and "no TTY" branches without a real terminal.
|
|
21
|
+
*
|
|
22
|
+
* A usable interactive agent needs BOTH stdin and stdout to be TTYs: stdin so
|
|
23
|
+
* the operator can type into the agent's TUI, stdout so it can render. We read
|
|
24
|
+
* them through this indirection rather than touching `process.std*.isTTY`
|
|
25
|
+
* inline.
|
|
26
|
+
*/
|
|
27
|
+
export interface TtyProbe {
|
|
28
|
+
stdinIsTty: boolean;
|
|
29
|
+
stdoutIsTty: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** Read the real process TTY state. */
|
|
32
|
+
export declare function processTtyProbe(): TtyProbe;
|
|
33
|
+
/**
|
|
34
|
+
* Injectable spawn signature (mirrors `work.ts`'s `SpawnFn`) so the interactive
|
|
35
|
+
* agent can be unit-tested without launching a real `claude`/`codex` process.
|
|
36
|
+
*/
|
|
37
|
+
export type AgentSpawnFn = (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
|
38
|
+
/**
|
|
39
|
+
* Assert a usable local TTY is present, or fail loud. This is the fail-fast
|
|
40
|
+
* gate the spec's "Fail-fast without usable TTY" constraint requires: an
|
|
41
|
+
* interactive run with no terminal to attach to must error immediately rather
|
|
42
|
+
* than hang waiting on input that can never arrive (or silently degrade to a
|
|
43
|
+
* non-interactive run, which would mask the misconfiguration).
|
|
44
|
+
*/
|
|
45
|
+
export declare function assertUsableTty(probe: TtyProbe): void;
|
|
46
|
+
/**
|
|
47
|
+
* Local-only guardrails for the interactive agent (spec: "No remote or shared
|
|
48
|
+
* shells", "Local single-operator agent scope"). These are STRUCTURAL
|
|
49
|
+
* guardrails: the agent never exposes a remote/shared surface in the first
|
|
50
|
+
* place. The interactive agent is, by construction, the operator's own
|
|
51
|
+
* `claude`/`codex` attached to *this* process's own terminal — there is no
|
|
52
|
+
* socket, no listener, no second connection, and it is bound to this process
|
|
53
|
+
* group so it cannot outlive (or be detached from) the local task.
|
|
54
|
+
* `buildLocalAgentOptions` is the single source of truth for those spawn
|
|
55
|
+
* options, and `assertLocalAgentOptions` enforces them so a future edit cannot
|
|
56
|
+
* silently introduce a detached or non-local agent session.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildLocalAgentOptions(): SpawnOptions;
|
|
59
|
+
/**
|
|
60
|
+
* Guard the spawn options against any non-local/shared configuration. Fails
|
|
61
|
+
* loud (no masked default) if a caller ever hands the agent options that would
|
|
62
|
+
* detach it from this process or route its IO anywhere but this terminal.
|
|
63
|
+
*/
|
|
64
|
+
export declare function assertLocalAgentOptions(options: SpawnOptions): void;
|
|
65
|
+
/**
|
|
66
|
+
* A live, interactive coding-agent session that replaces a task's headless run.
|
|
67
|
+
* `open` spawns `claude`/`codex` with inherited stdio (so the operator works
|
|
68
|
+
* with it directly, in its own TUI); the returned child resolves the run when
|
|
69
|
+
* it exits, which releases the worker. Constructed only after `assertUsableTty`
|
|
70
|
+
* has passed.
|
|
71
|
+
*
|
|
72
|
+
* Single-operator invariant: an instance opens AT MOST one agent session, ever.
|
|
73
|
+
* A second `open()` — whether the first session is still live or already
|
|
74
|
+
* closed — is refused loudly, so there is never more than one interactive agent
|
|
75
|
+
* behind a single task.
|
|
76
|
+
*/
|
|
77
|
+
export declare class InteractiveAgent {
|
|
78
|
+
private readonly command;
|
|
79
|
+
private readonly args;
|
|
80
|
+
private readonly spawnFn;
|
|
81
|
+
private child;
|
|
82
|
+
private opened;
|
|
83
|
+
constructor(command: string, args: string[], spawnFn?: AgentSpawnFn);
|
|
84
|
+
/**
|
|
85
|
+
* Spawn the coding agent with inherited stdio so it owns the terminal and the
|
|
86
|
+
* operator works with it live. Returns the child so the caller can await its
|
|
87
|
+
* exit (which releases the worker). The single-operator invariant is enforced
|
|
88
|
+
* here: one session per instance, period.
|
|
89
|
+
*/
|
|
90
|
+
open(): ChildProcess;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=interactive-agent.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interactive-agent.d.ts","sourceRoot":"","sources":["../../src/commands/interactive-agent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIrE;;;;;;;;;;;;;;;GAeG;AAEH;;;;;;;;GAQG;AACH,MAAM,WAAW,QAAQ;IACvB,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,uCAAuC;AACvC,wBAAgB,eAAe,IAAI,QAAQ,CAK1C;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,CACzB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,YAAY,KAClB,YAAY,CAAC;AAElB;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,QAAQ,GAAG,IAAI,CAarD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,IAAI,YAAY,CAiBrD;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAcnE;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,gBAAgB;IAKzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAN1B,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,MAAM,CAAS;gBAGJ,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE,YAAoB;IAGhD;;;;;OAKG;IACH,IAAI,IAAI,YAAY;CAmBrB"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { CliError } from "../util/errors.js";
|
|
3
|
+
import { log } from "../util/logger.js";
|
|
4
|
+
/** Read the real process TTY state. */
|
|
5
|
+
export function processTtyProbe() {
|
|
6
|
+
return {
|
|
7
|
+
stdinIsTty: process.stdin.isTTY === true,
|
|
8
|
+
stdoutIsTty: process.stdout.isTTY === true,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Assert a usable local TTY is present, or fail loud. This is the fail-fast
|
|
13
|
+
* gate the spec's "Fail-fast without usable TTY" constraint requires: an
|
|
14
|
+
* interactive run with no terminal to attach to must error immediately rather
|
|
15
|
+
* than hang waiting on input that can never arrive (or silently degrade to a
|
|
16
|
+
* non-interactive run, which would mask the misconfiguration).
|
|
17
|
+
*/
|
|
18
|
+
export function assertUsableTty(probe) {
|
|
19
|
+
if (!probe.stdinIsTty || !probe.stdoutIsTty) {
|
|
20
|
+
throw new CliError("Interactive coding-agent mode requires a local terminal (TTY), but none is attached.\n\n" +
|
|
21
|
+
"`zenorm work --interactive` launches the coding agent as a live session, " +
|
|
22
|
+
"which needs an interactive stdin and stdout. This run has " +
|
|
23
|
+
`${probe.stdinIsTty ? "" : "no TTY stdin"}` +
|
|
24
|
+
`${!probe.stdinIsTty && !probe.stdoutIsTty ? " and " : ""}` +
|
|
25
|
+
`${probe.stdoutIsTty ? "" : "no TTY stdout"}` +
|
|
26
|
+
".\n\nRun it from a real terminal, or drop `--interactive` (and the " +
|
|
27
|
+
'`interactive` config flag) to run non-interactively.');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Local-only guardrails for the interactive agent (spec: "No remote or shared
|
|
32
|
+
* shells", "Local single-operator agent scope"). These are STRUCTURAL
|
|
33
|
+
* guardrails: the agent never exposes a remote/shared surface in the first
|
|
34
|
+
* place. The interactive agent is, by construction, the operator's own
|
|
35
|
+
* `claude`/`codex` attached to *this* process's own terminal — there is no
|
|
36
|
+
* socket, no listener, no second connection, and it is bound to this process
|
|
37
|
+
* group so it cannot outlive (or be detached from) the local task.
|
|
38
|
+
* `buildLocalAgentOptions` is the single source of truth for those spawn
|
|
39
|
+
* options, and `assertLocalAgentOptions` enforces them so a future edit cannot
|
|
40
|
+
* silently introduce a detached or non-local agent session.
|
|
41
|
+
*/
|
|
42
|
+
export function buildLocalAgentOptions() {
|
|
43
|
+
return {
|
|
44
|
+
cwd: process.cwd(),
|
|
45
|
+
// inherit: the agent owns THIS terminal directly (its TUI, raw-mode line
|
|
46
|
+
// editing, etc. all work). Crucially this is also the local/single-operator
|
|
47
|
+
// boundary — the agent reads/writes only this process's stdio, never a pipe
|
|
48
|
+
// we could forward elsewhere. We are explicitly NOT piping the agent's
|
|
49
|
+
// output through Node (that piping + re-render is the headless path), so
|
|
50
|
+
// there is no second stream a remote/shared peer could attach, and no raw
|
|
51
|
+
// text dump: the agent's own TUI renders directly to the terminal.
|
|
52
|
+
stdio: "inherit",
|
|
53
|
+
// detached:false keeps the agent in OUR process group so it dies with the
|
|
54
|
+
// local task process and can never become an orphaned, independently-reachable
|
|
55
|
+
// session. A detached agent would violate "no remote or shared shells" — so
|
|
56
|
+
// it is asserted false below.
|
|
57
|
+
detached: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Guard the spawn options against any non-local/shared configuration. Fails
|
|
62
|
+
* loud (no masked default) if a caller ever hands the agent options that would
|
|
63
|
+
* detach it from this process or route its IO anywhere but this terminal.
|
|
64
|
+
*/
|
|
65
|
+
export function assertLocalAgentOptions(options) {
|
|
66
|
+
if (options.detached === true) {
|
|
67
|
+
throw new CliError("Refusing to launch a detached interactive agent: it must stay bound " +
|
|
68
|
+
"to the local task process (no remote or shared shells).");
|
|
69
|
+
}
|
|
70
|
+
if (options.stdio !== "inherit") {
|
|
71
|
+
throw new CliError("Refusing to launch the interactive agent on non-inherited stdio: it must " +
|
|
72
|
+
"attach to this local terminal only (no remote or shared shells, no raw " +
|
|
73
|
+
"text dumping).");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A live, interactive coding-agent session that replaces a task's headless run.
|
|
78
|
+
* `open` spawns `claude`/`codex` with inherited stdio (so the operator works
|
|
79
|
+
* with it directly, in its own TUI); the returned child resolves the run when
|
|
80
|
+
* it exits, which releases the worker. Constructed only after `assertUsableTty`
|
|
81
|
+
* has passed.
|
|
82
|
+
*
|
|
83
|
+
* Single-operator invariant: an instance opens AT MOST one agent session, ever.
|
|
84
|
+
* A second `open()` — whether the first session is still live or already
|
|
85
|
+
* closed — is refused loudly, so there is never more than one interactive agent
|
|
86
|
+
* behind a single task.
|
|
87
|
+
*/
|
|
88
|
+
export class InteractiveAgent {
|
|
89
|
+
command;
|
|
90
|
+
args;
|
|
91
|
+
spawnFn;
|
|
92
|
+
child = null;
|
|
93
|
+
opened = false;
|
|
94
|
+
constructor(command, args, spawnFn = spawn) {
|
|
95
|
+
this.command = command;
|
|
96
|
+
this.args = args;
|
|
97
|
+
this.spawnFn = spawnFn;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Spawn the coding agent with inherited stdio so it owns the terminal and the
|
|
101
|
+
* operator works with it live. Returns the child so the caller can await its
|
|
102
|
+
* exit (which releases the worker). The single-operator invariant is enforced
|
|
103
|
+
* here: one session per instance, period.
|
|
104
|
+
*/
|
|
105
|
+
open() {
|
|
106
|
+
if (this.opened) {
|
|
107
|
+
throw new CliError("Interactive agent already launched for this task — only a single local " +
|
|
108
|
+
"agent session is allowed (no second or shared session).");
|
|
109
|
+
}
|
|
110
|
+
this.opened = true;
|
|
111
|
+
const options = buildLocalAgentOptions();
|
|
112
|
+
// Belt-and-braces: prove the options are local-only before spawning, so a
|
|
113
|
+
// future change to buildLocalAgentOptions can't quietly launch a shared agent.
|
|
114
|
+
assertLocalAgentOptions(options);
|
|
115
|
+
log.plain(`Launching interactive ${this.command} session — work with it, then exit ` +
|
|
116
|
+
"the agent to continue to the next task.");
|
|
117
|
+
this.child = this.spawnFn(this.command, this.args, options);
|
|
118
|
+
return this.child;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pull.d.ts","sourceRoot":"","sources":["../../src/commands/pull.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"pull.d.ts","sourceRoot":"","sources":["../../src/commands/pull.ts"],"names":[],"mappings":"AAcA,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA4F/D"}
|
package/dist/commands/pull.js
CHANGED
|
@@ -3,7 +3,7 @@ import { writeFileSync } from "node:fs";
|
|
|
3
3
|
import { ZenormClient } from "../api/client.js";
|
|
4
4
|
import { CliError } from "../util/errors.js";
|
|
5
5
|
import { log } from "../util/logger.js";
|
|
6
|
-
import { blocksToMarkdown, specToMarkdown } from "../util/markdown.js";
|
|
6
|
+
import { attachmentsToMarkdown, blocksToMarkdown, specToMarkdown, } from "../util/markdown.js";
|
|
7
7
|
export async function pullCommand(argv) {
|
|
8
8
|
const { positionals, values } = parseArgs({
|
|
9
9
|
args: argv,
|
|
@@ -56,7 +56,28 @@ export async function pullCommand(argv) {
|
|
|
56
56
|
error: err instanceof Error ? err.message : String(err),
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
// Fetch the spec's READY attachments and append them to the work context,
|
|
60
|
+
// automatically (no flag, no per-run selection — AC "included automatically").
|
|
61
|
+
// The `?status=ready` filter is the SERVER-SIDE gate, so a worker run can only
|
|
62
|
+
// ever see ingested files (AC "ready files only"). Tolerate the endpoint 404ing
|
|
63
|
+
// on older servers — degrade silently like the blocks fetch above.
|
|
64
|
+
let attachmentsMd = "";
|
|
65
|
+
try {
|
|
66
|
+
const { attachments } = await client.get(`/v1/specs/${spec.id}/attachments?status=ready`);
|
|
67
|
+
attachmentsMd = attachmentsToMarkdown(attachments);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
log.debug("Attachment fetch failed, omitting attachments section", {
|
|
71
|
+
specId: spec.id,
|
|
72
|
+
error: err instanceof Error ? err.message : String(err),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const sections = [specToMarkdown(spec)];
|
|
76
|
+
if (blocksMd)
|
|
77
|
+
sections.push(blocksMd);
|
|
78
|
+
if (attachmentsMd)
|
|
79
|
+
sections.push(attachmentsMd);
|
|
80
|
+
const md = `${sections.join("\n\n")}\n`;
|
|
60
81
|
if (outputPath) {
|
|
61
82
|
writeFileSync(outputPath, md, "utf-8");
|
|
62
83
|
log.success(`Wrote spec to ${outputPath}`);
|
|
@@ -83,6 +83,7 @@ export declare function splashLogo(): string;
|
|
|
83
83
|
*/
|
|
84
84
|
export declare function daemonBanner(opts: {
|
|
85
85
|
agent: string;
|
|
86
|
+
model?: string;
|
|
86
87
|
repo: string;
|
|
87
88
|
intervalSeconds: number;
|
|
88
89
|
once: boolean;
|
|
@@ -112,6 +113,27 @@ export declare function failureBanner(opts: {
|
|
|
112
113
|
title: string;
|
|
113
114
|
error: string;
|
|
114
115
|
}): string;
|
|
116
|
+
/**
|
|
117
|
+
* The ONE-LINE post-agent change summary printed when an interactive
|
|
118
|
+
* coding-agent session closes (operator exited the agent). It is the only
|
|
119
|
+
* read-out of an interactive session — the agent's own TUI owned the terminal,
|
|
120
|
+
* so there is no transcript to footer — and per the spec it must be a SINGLE
|
|
121
|
+
* line (constraint: "No raw text terminal dumping"): no multi-line dump, no raw
|
|
122
|
+
* diff. It reports the files the agent touched during the session and the
|
|
123
|
+
* claimed task's resulting status, then the daemon releases the worker.
|
|
124
|
+
*
|
|
125
|
+
* The file list is capped to `maxFiles` names with a `+N more` tail so a large
|
|
126
|
+
* change set never wraps the line. `status` is the task's server-side status
|
|
127
|
+
* (e.g. `done` once the agent ran `zenorm task complete`), or the literal
|
|
128
|
+
* `unknown` when the status read could not be made — the one sanctioned
|
|
129
|
+
* soft-fallback, since reading the status must never block worker release.
|
|
130
|
+
*/
|
|
131
|
+
export declare function postAgentSummary(opts: {
|
|
132
|
+
filesTouched: string[];
|
|
133
|
+
status: string;
|
|
134
|
+
shortTaskId: string;
|
|
135
|
+
maxFiles?: number;
|
|
136
|
+
}): string;
|
|
115
137
|
/**
|
|
116
138
|
* The session summary printed when the daemon shuts down (Ctrl-C or `--once`
|
|
117
139
|
* exhausted). Closes the run with a rule + a one-line tally so the user sees the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"work-render.d.ts","sourceRoot":"","sources":["../../src/commands/work-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAoBH,+DAA+D;AAC/D,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC;AA2DlC;;;GAGG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,MAAM,CAAM;IAEpB,6EAA6E;IAC7E,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE;IAQ7B,wDAAwD;IACxD,KAAK,IAAI,MAAM,EAAE;CAMlB;AA+ED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,YAAY,EAAE,CAkChE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,YAAY,EAAE,CAoE/D;AAED,kEAAkE;AAClE,MAAM,MAAM,aAAa,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,YAAY,EAAE,CAAC;AAuBjE,eAAO,MAAM,cAAc,EAAE,aAA+C,CAAC;AAC7E,eAAO,MAAM,aAAa,EAAE,aAA8C,CAAC;AAE3E;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,YAAY,CAI/E;AAMD;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAMR;AA2JD;;;;;;;;GAQG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAenC;AAoBD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;IACxB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;CACjB,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"work-render.d.ts","sourceRoot":"","sources":["../../src/commands/work-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAoBH,+DAA+D;AAC/D,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC;AA2DlC;;;GAGG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,MAAM,CAAM;IAEpB,6EAA6E;IAC7E,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE;IAQ7B,wDAAwD;IACxD,KAAK,IAAI,MAAM,EAAE;CAMlB;AA+ED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,YAAY,EAAE,CAkChE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,YAAY,EAAE,CAoE/D;AAED,kEAAkE;AAClE,MAAM,MAAM,aAAa,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,YAAY,EAAE,CAAC;AAuBjE,eAAO,MAAM,cAAc,EAAE,aAA+C,CAAC;AAC7E,eAAO,MAAM,aAAa,EAAE,aAA8C,CAAC;AAE3E;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,YAAY,CAI/E;AAMD;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAMR;AA2JD;;;;;;;;GAQG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAenC;AAoBD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;IACxB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;CACjB,GAAG,MAAM,CAuBT;AAED,8EAA8E;AAC9E,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE;IAChC,EAAE,EAAE,OAAO,CAAC;IACZ,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,MAAM,CAKT;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,MAAM,CAMT;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IACrC,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,MAAM,CAiBT;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,MAAM,CAKT"}
|
|
@@ -537,6 +537,7 @@ export function daemonBanner(opts) {
|
|
|
537
537
|
`${DIM}spec-authoring agent · claim-and-run daemon${RESET}`,
|
|
538
538
|
"",
|
|
539
539
|
`${DIM}agent ${RESET}${opts.agent}`,
|
|
540
|
+
...(opts.model ? [`${DIM}model ${RESET}${opts.model}`] : []),
|
|
540
541
|
`${DIM}repo ${RESET}${opts.repo}`,
|
|
541
542
|
`${DIM}interval ${RESET}${opts.intervalSeconds}s`,
|
|
542
543
|
]);
|
|
@@ -576,6 +577,38 @@ export function failureBanner(opts) {
|
|
|
576
577
|
`${DIM} released back to todo — it can be re-claimed.${RESET}`,
|
|
577
578
|
].join("\n");
|
|
578
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* The ONE-LINE post-agent change summary printed when an interactive
|
|
582
|
+
* coding-agent session closes (operator exited the agent). It is the only
|
|
583
|
+
* read-out of an interactive session — the agent's own TUI owned the terminal,
|
|
584
|
+
* so there is no transcript to footer — and per the spec it must be a SINGLE
|
|
585
|
+
* line (constraint: "No raw text terminal dumping"): no multi-line dump, no raw
|
|
586
|
+
* diff. It reports the files the agent touched during the session and the
|
|
587
|
+
* claimed task's resulting status, then the daemon releases the worker.
|
|
588
|
+
*
|
|
589
|
+
* The file list is capped to `maxFiles` names with a `+N more` tail so a large
|
|
590
|
+
* change set never wraps the line. `status` is the task's server-side status
|
|
591
|
+
* (e.g. `done` once the agent ran `zenorm task complete`), or the literal
|
|
592
|
+
* `unknown` when the status read could not be made — the one sanctioned
|
|
593
|
+
* soft-fallback, since reading the status must never block worker release.
|
|
594
|
+
*/
|
|
595
|
+
export function postAgentSummary(opts) {
|
|
596
|
+
const max = opts.maxFiles ?? 4;
|
|
597
|
+
const count = opts.filesTouched.length;
|
|
598
|
+
const noun = count === 1 ? "file" : "files";
|
|
599
|
+
let detail;
|
|
600
|
+
if (count === 0) {
|
|
601
|
+
detail = `${DIM}no files touched${RESET}`;
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
const shown = opts.filesTouched.slice(0, max);
|
|
605
|
+
const overflow = count - shown.length;
|
|
606
|
+
const names = shown.join(", ") + (overflow > 0 ? `, +${overflow} more` : "");
|
|
607
|
+
detail = `${count} ${noun} touched ${DIM}(${clip(names)})${RESET}`;
|
|
608
|
+
}
|
|
609
|
+
return (`${BOLD}◣ agent closed${RESET} ${DIM}·${RESET} ${detail} ` +
|
|
610
|
+
`${DIM}·${RESET} task ${GREEN}${opts.status}${RESET} ${DIM}(${opts.shortTaskId})${RESET}`);
|
|
611
|
+
}
|
|
579
612
|
/**
|
|
580
613
|
* The session summary printed when the daemon shuts down (Ctrl-C or `--once`
|
|
581
614
|
* exhausted). Closes the run with a rule + a one-line tally so the user sees the
|
package/dist/commands/work.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SpawnOptions, ChildProcess } from "node:child_process";
|
|
2
2
|
import { ZenormClient } from "../api/client.js";
|
|
3
3
|
import type { SpecTaskRecord } from "../api/types.js";
|
|
4
|
+
import { type TtyProbe } from "./interactive-agent.js";
|
|
4
5
|
/**
|
|
5
6
|
* Agents whose subprocess runner slice 3 will wire up. Validated here so an
|
|
6
7
|
* unknown/missing `--agent` fails loud rather than silently defaulting (project
|
|
@@ -35,6 +36,13 @@ export interface TaskRunner {
|
|
|
35
36
|
* Exported for unit testing.
|
|
36
37
|
*/
|
|
37
38
|
export declare function buildHandoffPrompt(task: SpecTaskRecord): string;
|
|
39
|
+
/**
|
|
40
|
+
* The interactive (coding-agent mode) handoff prompt. Same spec-mode handoff as
|
|
41
|
+
* the headless prompt, MINUS the "work non-interactively" instruction: in this
|
|
42
|
+
* mode the agent runs as a live session the operator drives, so it SHOULD pause
|
|
43
|
+
* for input. Exported for unit testing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildInteractiveHandoffPrompt(task: SpecTaskRecord): string;
|
|
38
46
|
/**
|
|
39
47
|
* The headless subprocess invocation for a given agent + task. Pure (no
|
|
40
48
|
* side effects) so it can be unit-tested directly without spawning anything.
|
|
@@ -47,7 +55,37 @@ export declare function buildHandoffPrompt(task: SpecTaskRecord): string;
|
|
|
47
55
|
*
|
|
48
56
|
* Exported for unit testing.
|
|
49
57
|
*/
|
|
50
|
-
export declare function buildAgentCommand(agent: SupportedAgent, task: SpecTaskRecord): {
|
|
58
|
+
export declare function buildAgentCommand(agent: SupportedAgent, task: SpecTaskRecord, model?: string): {
|
|
59
|
+
command: string;
|
|
60
|
+
args: string[];
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* The INTERACTIVE invocation for a given agent + task: launch the agent as a
|
|
64
|
+
* live session (NOT headless) seeded with the spec handoff, so the operator
|
|
65
|
+
* works with it directly in its own TUI. Pure (no side effects) so it can be
|
|
66
|
+
* unit-tested without spawning anything.
|
|
67
|
+
*
|
|
68
|
+
* Unlike `buildAgentCommand`, there is no `-p`/`exec`/`--json` and no
|
|
69
|
+
* stream-json: both agents start an interactive session by default, and the
|
|
70
|
+
* agent's own TUI renders straight to the inherited terminal (so there is no
|
|
71
|
+
* raw JSON event stream for us to re-render, and nothing to dump as raw text).
|
|
72
|
+
* - claude: `claude --permission-mode auto [--model <m>] "<prompt>"`
|
|
73
|
+
* (interactive by default; `-p` would make it headless, so it is deliberately
|
|
74
|
+
* absent). `--permission-mode auto` runs the background classifier that
|
|
75
|
+
* auto-approves safe actions and only blocks destructive ones, so the
|
|
76
|
+
* operator is not interrupted by a permission prompt on every gated tool call
|
|
77
|
+
* — same guardrail the headless path uses, just keeping the session live.
|
|
78
|
+
* - codex: `codex --ask-for-approval never --sandbox workspace-write [-m <m>]
|
|
79
|
+
* "<prompt>"` (interactive by default; the `exec` subcommand is what makes it
|
|
80
|
+
* non-interactive, so it is deliberately absent). `--ask-for-approval never`
|
|
81
|
+
* is codex's full-auto: the operator is never interrupted by an approval
|
|
82
|
+
* prompt, while `--sandbox workspace-write` still confines writes to the
|
|
83
|
+
* workspace (NOT `--dangerously-bypass-approvals-and-sandbox`, which would
|
|
84
|
+
* drop the sandbox entirely).
|
|
85
|
+
*
|
|
86
|
+
* Exported for unit testing.
|
|
87
|
+
*/
|
|
88
|
+
export declare function buildInteractiveAgentCommand(agent: SupportedAgent, task: SpecTaskRecord, model?: string): {
|
|
51
89
|
command: string;
|
|
52
90
|
args: string[];
|
|
53
91
|
};
|
|
@@ -56,6 +94,31 @@ export declare function buildAgentCommand(agent: SupportedAgent, task: SpecTaskR
|
|
|
56
94
|
* unit-tested without launching a real agent.
|
|
57
95
|
*/
|
|
58
96
|
export type SpawnFn = (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
|
97
|
+
/**
|
|
98
|
+
* Snapshot the working tree's changed-file state. Injectable so the interactive
|
|
99
|
+
* post-agent summary can be unit-tested without a real git repo; the default is
|
|
100
|
+
* a real `git status --porcelain` in the current repo. Returns the raw porcelain
|
|
101
|
+
* text (each line is a 3-char status prefix + path); `diffTouchedFiles` parses
|
|
102
|
+
* it into the touched-file set.
|
|
103
|
+
*/
|
|
104
|
+
export type GitStatusFn = () => string;
|
|
105
|
+
/**
|
|
106
|
+
* Read the claimed task's CURRENT server-side status (e.g. `done` once the agent
|
|
107
|
+
* ran `zenorm task complete` inside the session). Injectable so the summary can
|
|
108
|
+
* be unit-tested without HTTP. Returns the status, or `"unknown"` when the read
|
|
109
|
+
* could not be made — the summary must never block worker release on a status
|
|
110
|
+
* read, so this is the one sanctioned soft-fallback (the caller also warns).
|
|
111
|
+
*/
|
|
112
|
+
export type FetchTaskStatusFn = (task: SpecTaskRecord) => Promise<SpecTaskRecord["status"]>;
|
|
113
|
+
/**
|
|
114
|
+
* Diff two `git status --porcelain` snapshots into the set of files the agent
|
|
115
|
+
* touched during the session. We compare the porcelain LINE per path across
|
|
116
|
+
* before/after: a path is "touched" when its line differs (added, removed, or
|
|
117
|
+
* its status code changed). This deliberately IGNORES pre-existing dirt whose
|
|
118
|
+
* status did not change during the session (so unrelated stray files already in
|
|
119
|
+
* the tree don't pollute the summary). Returns a sorted, deduped path list.
|
|
120
|
+
*/
|
|
121
|
+
export declare function diffTouchedFiles(before: string, after: string): string[];
|
|
59
122
|
/**
|
|
60
123
|
* The real runner: spawns the chosen coding agent headlessly against the
|
|
61
124
|
* claimed task and awaits its exit.
|
|
@@ -69,9 +132,48 @@ export type SpawnFn = (command: string, args: string[], options: SpawnOptions) =
|
|
|
69
132
|
export declare class AgentRunner implements TaskRunner {
|
|
70
133
|
private readonly agent;
|
|
71
134
|
private readonly spawnFn;
|
|
135
|
+
private readonly model?;
|
|
136
|
+
private readonly interactive;
|
|
137
|
+
private readonly gitStatus;
|
|
138
|
+
private readonly fetchTaskStatus?;
|
|
72
139
|
readonly agentName: SupportedAgent;
|
|
73
|
-
constructor(agent: SupportedAgent, spawnFn?: SpawnFn);
|
|
140
|
+
constructor(agent: SupportedAgent, spawnFn?: SpawnFn, model?: string | undefined, interactive?: boolean, gitStatus?: GitStatusFn, fetchTaskStatus?: FetchTaskStatusFn | undefined);
|
|
74
141
|
run(task: SpecTaskRecord): Promise<void>;
|
|
142
|
+
/**
|
|
143
|
+
* Default (non-interactive) path — UNCHANGED behavior: spawn the agent
|
|
144
|
+
* headlessly, pipe + re-render its JSON event stream, await its exit.
|
|
145
|
+
*/
|
|
146
|
+
private runHeadless;
|
|
147
|
+
/**
|
|
148
|
+
* Coding-agent (interactive) path. Launch the agent as a LIVE session via
|
|
149
|
+
* `InteractiveAgent`: inherited stdio so the agent's own TUI owns the terminal
|
|
150
|
+
* (no piping, no re-render — and therefore no raw text dumping), seeded with
|
|
151
|
+
* the spec handoff. The operator works with it; when they close the agent it
|
|
152
|
+
* exits, which resolves this run and RELEASES THE WORKER so the loop claims
|
|
153
|
+
* the next task. A clean operator exit (code 0) is success; a crash/non-zero
|
|
154
|
+
* exit is still a loud failure.
|
|
155
|
+
*/
|
|
156
|
+
private runInteractive;
|
|
157
|
+
/**
|
|
158
|
+
* Print the ONE-LINE post-agent change summary, then RESOLVE (releasing the
|
|
159
|
+
* worker) — in that order, per the spec constraint "Summary before worker
|
|
160
|
+
* release". Computes the touched-file set from the before/after porcelain
|
|
161
|
+
* snapshots and reads the claimed task's current server-side status.
|
|
162
|
+
*
|
|
163
|
+
* Robustness: this runs after a CLEAN agent exit, so it must NEVER turn that
|
|
164
|
+
* success into a failure. The whole body is wrapped so `resolve()` always
|
|
165
|
+
* fires even if the summary work throws. The status read is the one sanctioned
|
|
166
|
+
* soft-fallback — a fetch failure warns and reports `unknown` rather than
|
|
167
|
+
* blocking the release; an absent status fetcher (no client wired, e.g. unit
|
|
168
|
+
* tests) is silently `unknown`.
|
|
169
|
+
*/
|
|
170
|
+
private summarizeThenRelease;
|
|
171
|
+
/**
|
|
172
|
+
* Shared exit/error handling for both paths: ENOENT → install hint, non-zero
|
|
173
|
+
* exit → loud failure, code 0 → success (releases the worker). `settled`
|
|
174
|
+
* guards against a double-settle if error and exit both fire.
|
|
175
|
+
*/
|
|
176
|
+
private wireChild;
|
|
75
177
|
}
|
|
76
178
|
/**
|
|
77
179
|
* `--dry-run` runner: prints the exact headless command + handoff prompt it
|
|
@@ -80,8 +182,9 @@ export declare class AgentRunner implements TaskRunner {
|
|
|
80
182
|
*/
|
|
81
183
|
export declare class DryRunRunner implements TaskRunner {
|
|
82
184
|
private readonly agent;
|
|
185
|
+
private readonly model?;
|
|
83
186
|
readonly agentName: SupportedAgent;
|
|
84
|
-
constructor(agent: SupportedAgent);
|
|
187
|
+
constructor(agent: SupportedAgent, model?: string | undefined);
|
|
85
188
|
run(task: SpecTaskRecord): Promise<void>;
|
|
86
189
|
}
|
|
87
190
|
/**
|
|
@@ -99,7 +202,18 @@ export declare function parseGitRemote(url: string): {
|
|
|
99
202
|
interface DaemonDeps {
|
|
100
203
|
runner?: TaskRunner;
|
|
101
204
|
client?: ZenormClient;
|
|
205
|
+
ttyProbe?: TtyProbe;
|
|
102
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Resolve whether interactive mode is on. Opt-in is explicit by design (the
|
|
209
|
+
* spec keeps existing runs non-interactive unless enabled): the `--interactive`
|
|
210
|
+
* flag, or `interactive: true` in `.zenorm.json`. The flag wins; config only
|
|
211
|
+
* supplies the default when the flag is absent. A bad config value already
|
|
212
|
+
* threw in `validateConfig`, so this never silently coerces.
|
|
213
|
+
*/
|
|
214
|
+
export declare function resolveInteractive(flagPassed: boolean, config: {
|
|
215
|
+
interactive?: boolean;
|
|
216
|
+
} | null): boolean;
|
|
103
217
|
/**
|
|
104
218
|
* The poll loop, isolated from arg/repo resolution so it can be unit-tested
|
|
105
219
|
* with an injected client and runner. Returns the number of tasks processed.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"work.d.ts","sourceRoot":"","sources":["../../src/commands/work.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"work.d.ts","sourceRoot":"","sources":["../../src/commands/work.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,KAAK,EAGV,cAAc,EAEf,MAAM,iBAAiB,CAAC;AAIzB,OAAO,EAIL,KAAK,QAAQ,EACd,MAAM,wBAAwB,CAAC;AAehC;;;;GAIG;AACH,QAAA,MAAM,gBAAgB,8BAA+B,CAAC;AACtD,KAAK,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAuCxD;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,GAAG,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAeD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,CAS/D;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,CAG1E;AAsBD;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,cAAc,EACrB,IAAI,EAAE,cAAc,EACpB,KAAK,CAAC,EAAE,MAAM,GACb;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAwDrC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,4BAA4B,CAC1C,KAAK,EAAE,cAAc,EACrB,IAAI,EAAE,cAAc,EACpB,KAAK,CAAC,EAAE,MAAM,GACb;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CA2BrC;AAQD;;;GAGG;AACH,MAAM,MAAM,OAAO,GAAG,CACpB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,YAAY,KAClB,YAAY,CAAC;AAElB;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,MAAM,CAAC;AAEvC;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,IAAI,EAAE,cAAc,KAAK,OAAO,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC;AAuB5F;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAgBxE;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAY,YAAW,UAAU;IAI1C,OAAO,CAAC,QAAQ,CAAC,KAAK;IAEtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAExB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IAOvB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAI5B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAM1B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC;IAxBnC,SAAgB,SAAS,EAAE,cAAc,CAAC;gBAGvB,KAAK,EAAE,cAAc,EAErB,OAAO,GAAE,OAAe,EAExB,KAAK,CAAC,EAAE,MAAM,YAAA,EAOd,WAAW,UAAQ,EAInB,SAAS,GAAE,WAA2B,EAMtC,eAAe,CAAC,EAAE,iBAAiB,YAAA;IAKtD,GAAG,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC;;;OAGG;IACH,OAAO,CAAC,WAAW;IAkCnB;;;;;;;;OAQG;IACH,OAAO,CAAC,cAAc;IAiCtB;;;;;;;;;;;;OAYG;YACW,oBAAoB;IAwClC;;;;OAIG;IACH,OAAO,CAAC,SAAS;CA+ClB;AAED;;;;GAIG;AACH,qBAAa,YAAa,YAAW,UAAU;IAI3C,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IAJzB,SAAgB,SAAS,EAAE,cAAc,CAAC;gBAGvB,KAAK,EAAE,cAAc,EACrB,KAAK,CAAC,EAAE,MAAM,YAAA;IAKjC,GAAG,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CAOzC;AAUD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAmD3E;AA4BD,UAAU,UAAU;IAClB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,MAAM,CAAC,EAAE,YAAY,CAAC;IAGtB,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACrB;AAwFD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,OAAO,EACnB,MAAM,EAAE;IAAE,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,GACvC,OAAO,CAGT;AAoED;;;GAGG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE;IACtC,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,OAAO,CAAC;CACf,GAAG,OAAO,CAAC,MAAM,CAAC,CA2HlB;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,CAAC,EAAE,UAAU,GAChB,OAAO,CAAC,IAAI,CAAC,CA+Df"}
|
package/dist/commands/work.js
CHANGED
|
@@ -3,7 +3,9 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
3
3
|
import { ZenormClient } from "../api/client.js";
|
|
4
4
|
import { CliError } from "../util/errors.js";
|
|
5
5
|
import { log } from "../util/logger.js";
|
|
6
|
-
import {
|
|
6
|
+
import { loadConfig } from "../config/loader.js";
|
|
7
|
+
import { InteractiveAgent, assertUsableTty, processTtyProbe, } from "./interactive-agent.js";
|
|
8
|
+
import { LineSplitter, claudeRenderer, codexRenderer, daemonBanner, failureBanner, postAgentSummary, sessionSummary, statusLine, taskHeader, taskOutcome, } from "./work-render.js";
|
|
7
9
|
/**
|
|
8
10
|
* Agents whose subprocess runner slice 3 will wire up. Validated here so an
|
|
9
11
|
* unknown/missing `--agent` fails loud rather than silently defaulting (project
|
|
@@ -19,7 +21,7 @@ const MAX_INTERVAL_SECONDS = 10;
|
|
|
19
21
|
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
20
22
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
21
23
|
const USAGE = `Usage:
|
|
22
|
-
zenorm work --agent <${SUPPORTED_AGENTS.join("|")}> [--interval <seconds>] [--once] [--dry-run]
|
|
24
|
+
zenorm work --agent <${SUPPORTED_AGENTS.join("|")}> [--model <model>] [--interval <seconds>] [--once] [--dry-run] [--interactive]
|
|
23
25
|
|
|
24
26
|
Runs a daemon that claims and processes ready tasks for the current git
|
|
25
27
|
repository (resolved from \`git remote get-url origin\`), oldest-first, one at a
|
|
@@ -28,10 +30,23 @@ bundled ZeNorm \`execute-task\` skill and posts the task outcome itself.
|
|
|
28
30
|
|
|
29
31
|
Options:
|
|
30
32
|
--agent <agent> Agent to run each task with. Required. One of: ${SUPPORTED_AGENTS.join(", ")}
|
|
33
|
+
--model <model> Model the agent runs each task with (alias e.g. opus,
|
|
34
|
+
sonnet, or a full model id). Optional; when omitted the
|
|
35
|
+
agent uses its own configured default. For claude this
|
|
36
|
+
also retargets the server-side advisor tool to the same
|
|
37
|
+
model, so a gated \`advisorModel\` no longer 400s the run.
|
|
31
38
|
--interval <seconds> Poll interval when idle. Default ${DEFAULT_INTERVAL_SECONDS}, max ${MAX_INTERVAL_SECONDS}.
|
|
32
39
|
--once Process at most one claim (or one idle poll) then exit.
|
|
33
40
|
--dry-run Print the agent command + handoff prompt each task would
|
|
34
|
-
run instead of spawning the agent
|
|
41
|
+
run instead of spawning the agent.
|
|
42
|
+
--interactive Opt into coding-agent mode: instead of running each task
|
|
43
|
+
headlessly, launch the active coding agent (the one named
|
|
44
|
+
by --agent) as a LIVE interactive session seeded with the
|
|
45
|
+
spec handoff. Work with the agent, then exit it to release
|
|
46
|
+
the worker and continue to the next task. Local terminal
|
|
47
|
+
only — requires a usable TTY and fails fast without one.
|
|
48
|
+
Can also be enabled per project via \`interactive: true\`
|
|
49
|
+
in .zenorm.json.`;
|
|
35
50
|
/**
|
|
36
51
|
* Headless install hints, surfaced when an agent binary is missing so the
|
|
37
52
|
* failure is actionable rather than a bare ENOENT.
|
|
@@ -60,6 +75,26 @@ const AGENT_INSTALL_HINT = {
|
|
|
60
75
|
*/
|
|
61
76
|
export function buildHandoffPrompt(task) {
|
|
62
77
|
const zenormCli = process.env["ZENORM_CLI"]?.trim() || "zenorm";
|
|
78
|
+
return [
|
|
79
|
+
...handoffPromptBody(task, zenormCli),
|
|
80
|
+
// Headless runs have no operator to wait on — tell the agent to drive
|
|
81
|
+
// straight through without pausing for input. The interactive variant
|
|
82
|
+
// (buildInteractiveHandoffPrompt) deliberately omits this line.
|
|
83
|
+
"Work non-interactively; do not wait for further input.",
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The interactive (coding-agent mode) handoff prompt. Same spec-mode handoff as
|
|
88
|
+
* the headless prompt, MINUS the "work non-interactively" instruction: in this
|
|
89
|
+
* mode the agent runs as a live session the operator drives, so it SHOULD pause
|
|
90
|
+
* for input. Exported for unit testing.
|
|
91
|
+
*/
|
|
92
|
+
export function buildInteractiveHandoffPrompt(task) {
|
|
93
|
+
const zenormCli = process.env["ZENORM_CLI"]?.trim() || "zenorm";
|
|
94
|
+
return handoffPromptBody(task, zenormCli).join("\n");
|
|
95
|
+
}
|
|
96
|
+
/** Shared spec-mode handoff lines for both the headless and interactive prompts. */
|
|
97
|
+
function handoffPromptBody(task, zenormCli) {
|
|
63
98
|
return [
|
|
64
99
|
`/zenorm ${task.specId}`,
|
|
65
100
|
"",
|
|
@@ -74,9 +109,8 @@ export function buildHandoffPrompt(task) {
|
|
|
74
109
|
"ready tasks and implement it alongside the rest. Implement everything",
|
|
75
110
|
"end-to-end in the current repository working directory using the existing",
|
|
76
111
|
"ZeNorm execute workflow, completing each task via the normal",
|
|
77
|
-
`\`${zenormCli} task complete <task-id>\` flow as you finish it
|
|
78
|
-
|
|
79
|
-
].join("\n");
|
|
112
|
+
`\`${zenormCli} task complete <task-id>\` flow as you finish it.`,
|
|
113
|
+
];
|
|
80
114
|
}
|
|
81
115
|
/**
|
|
82
116
|
* The headless subprocess invocation for a given agent + task. Pure (no
|
|
@@ -90,7 +124,7 @@ export function buildHandoffPrompt(task) {
|
|
|
90
124
|
*
|
|
91
125
|
* Exported for unit testing.
|
|
92
126
|
*/
|
|
93
|
-
export function buildAgentCommand(agent, task) {
|
|
127
|
+
export function buildAgentCommand(agent, task, model) {
|
|
94
128
|
const prompt = buildHandoffPrompt(task);
|
|
95
129
|
switch (agent) {
|
|
96
130
|
case "claude":
|
|
@@ -116,11 +150,29 @@ export function buildAgentCommand(agent, task) {
|
|
|
116
150
|
"--verbose",
|
|
117
151
|
"--permission-mode",
|
|
118
152
|
"auto",
|
|
153
|
+
// --model sets the main session model; it does NOT retarget the
|
|
154
|
+
// server-side advisor tool, which carries its OWN model from the
|
|
155
|
+
// `advisorModel` setting. If that setting points at a model the
|
|
156
|
+
// account can't access (e.g. a gated `claude-fable-5`), EVERY turn
|
|
157
|
+
// 400s with `tools.N.model: ... is not available`. So when the user
|
|
158
|
+
// pins a model we also override `advisorModel` for THIS session via
|
|
159
|
+
// --settings, keeping the advisor on a model we know is reachable.
|
|
160
|
+
...(model
|
|
161
|
+
? [
|
|
162
|
+
"--model",
|
|
163
|
+
model,
|
|
164
|
+
"--settings",
|
|
165
|
+
JSON.stringify({ advisorModel: model }),
|
|
166
|
+
]
|
|
167
|
+
: []),
|
|
119
168
|
prompt,
|
|
120
169
|
],
|
|
121
170
|
};
|
|
122
171
|
case "codex":
|
|
123
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
command: "codex",
|
|
174
|
+
args: ["exec", "--json", ...(model ? ["-m", model] : []), prompt],
|
|
175
|
+
};
|
|
124
176
|
default: {
|
|
125
177
|
// Exhaustiveness guard — fail loud if SUPPORTED_AGENTS grows without a
|
|
126
178
|
// matching headless invocation here (no error-masking default).
|
|
@@ -129,11 +181,112 @@ export function buildAgentCommand(agent, task) {
|
|
|
129
181
|
}
|
|
130
182
|
}
|
|
131
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* The INTERACTIVE invocation for a given agent + task: launch the agent as a
|
|
186
|
+
* live session (NOT headless) seeded with the spec handoff, so the operator
|
|
187
|
+
* works with it directly in its own TUI. Pure (no side effects) so it can be
|
|
188
|
+
* unit-tested without spawning anything.
|
|
189
|
+
*
|
|
190
|
+
* Unlike `buildAgentCommand`, there is no `-p`/`exec`/`--json` and no
|
|
191
|
+
* stream-json: both agents start an interactive session by default, and the
|
|
192
|
+
* agent's own TUI renders straight to the inherited terminal (so there is no
|
|
193
|
+
* raw JSON event stream for us to re-render, and nothing to dump as raw text).
|
|
194
|
+
* - claude: `claude --permission-mode auto [--model <m>] "<prompt>"`
|
|
195
|
+
* (interactive by default; `-p` would make it headless, so it is deliberately
|
|
196
|
+
* absent). `--permission-mode auto` runs the background classifier that
|
|
197
|
+
* auto-approves safe actions and only blocks destructive ones, so the
|
|
198
|
+
* operator is not interrupted by a permission prompt on every gated tool call
|
|
199
|
+
* — same guardrail the headless path uses, just keeping the session live.
|
|
200
|
+
* - codex: `codex --ask-for-approval never --sandbox workspace-write [-m <m>]
|
|
201
|
+
* "<prompt>"` (interactive by default; the `exec` subcommand is what makes it
|
|
202
|
+
* non-interactive, so it is deliberately absent). `--ask-for-approval never`
|
|
203
|
+
* is codex's full-auto: the operator is never interrupted by an approval
|
|
204
|
+
* prompt, while `--sandbox workspace-write` still confines writes to the
|
|
205
|
+
* workspace (NOT `--dangerously-bypass-approvals-and-sandbox`, which would
|
|
206
|
+
* drop the sandbox entirely).
|
|
207
|
+
*
|
|
208
|
+
* Exported for unit testing.
|
|
209
|
+
*/
|
|
210
|
+
export function buildInteractiveAgentCommand(agent, task, model) {
|
|
211
|
+
const prompt = buildInteractiveHandoffPrompt(task);
|
|
212
|
+
switch (agent) {
|
|
213
|
+
case "claude":
|
|
214
|
+
return {
|
|
215
|
+
command: "claude",
|
|
216
|
+
args: ["--permission-mode", "auto", ...(model ? ["--model", model] : []), prompt],
|
|
217
|
+
};
|
|
218
|
+
case "codex":
|
|
219
|
+
return {
|
|
220
|
+
command: "codex",
|
|
221
|
+
args: [
|
|
222
|
+
"--ask-for-approval",
|
|
223
|
+
"never",
|
|
224
|
+
"--sandbox",
|
|
225
|
+
"workspace-write",
|
|
226
|
+
...(model ? ["-m", model] : []),
|
|
227
|
+
prompt,
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
default: {
|
|
231
|
+
// Exhaustiveness guard — fail loud if SUPPORTED_AGENTS grows without a
|
|
232
|
+
// matching interactive invocation here (no error-masking default).
|
|
233
|
+
const never = agent;
|
|
234
|
+
throw new CliError(`No interactive invocation defined for agent "${String(never)}".`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
132
238
|
/** The transcript renderer for each agent's JSON event stream. */
|
|
133
239
|
const AGENT_RENDERER = {
|
|
134
240
|
claude: claudeRenderer,
|
|
135
241
|
codex: codexRenderer,
|
|
136
242
|
};
|
|
243
|
+
/** The real working-tree snapshot: `git status --porcelain` in the current repo. */
|
|
244
|
+
function realGitStatus() {
|
|
245
|
+
return execFileSync("git", ["status", "--porcelain"], {
|
|
246
|
+
cwd: process.cwd(),
|
|
247
|
+
encoding: "utf-8",
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Parse one `git status --porcelain` line into its path. The format is a 2-char
|
|
252
|
+
* status code + a space, then the path; a rename/copy renders as `old -> new`,
|
|
253
|
+
* for which we take the NEW path (`new`). Returns null for a blank line.
|
|
254
|
+
*/
|
|
255
|
+
function porcelainPath(line) {
|
|
256
|
+
if (line.trim().length === 0)
|
|
257
|
+
return null;
|
|
258
|
+
// Drop the 3-char status prefix ("XY ").
|
|
259
|
+
const rest = line.slice(3);
|
|
260
|
+
const arrow = rest.indexOf(" -> ");
|
|
261
|
+
return arrow >= 0 ? rest.slice(arrow + 4) : rest;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Diff two `git status --porcelain` snapshots into the set of files the agent
|
|
265
|
+
* touched during the session. We compare the porcelain LINE per path across
|
|
266
|
+
* before/after: a path is "touched" when its line differs (added, removed, or
|
|
267
|
+
* its status code changed). This deliberately IGNORES pre-existing dirt whose
|
|
268
|
+
* status did not change during the session (so unrelated stray files already in
|
|
269
|
+
* the tree don't pollute the summary). Returns a sorted, deduped path list.
|
|
270
|
+
*/
|
|
271
|
+
export function diffTouchedFiles(before, after) {
|
|
272
|
+
const index = (porcelain) => {
|
|
273
|
+
const map = new Map();
|
|
274
|
+
for (const line of porcelain.split("\n")) {
|
|
275
|
+
const path = porcelainPath(line);
|
|
276
|
+
if (path !== null)
|
|
277
|
+
map.set(path, line);
|
|
278
|
+
}
|
|
279
|
+
return map;
|
|
280
|
+
};
|
|
281
|
+
const beforeLines = index(before);
|
|
282
|
+
const afterLines = index(after);
|
|
283
|
+
const touched = new Set();
|
|
284
|
+
for (const path of new Set([...beforeLines.keys(), ...afterLines.keys()])) {
|
|
285
|
+
if (beforeLines.get(path) !== afterLines.get(path))
|
|
286
|
+
touched.add(path);
|
|
287
|
+
}
|
|
288
|
+
return [...touched].sort();
|
|
289
|
+
}
|
|
137
290
|
/**
|
|
138
291
|
* The real runner: spawns the chosen coding agent headlessly against the
|
|
139
292
|
* claimed task and awaits its exit.
|
|
@@ -147,16 +300,50 @@ const AGENT_RENDERER = {
|
|
|
147
300
|
export class AgentRunner {
|
|
148
301
|
agent;
|
|
149
302
|
spawnFn;
|
|
303
|
+
model;
|
|
304
|
+
interactive;
|
|
305
|
+
gitStatus;
|
|
306
|
+
fetchTaskStatus;
|
|
150
307
|
agentName;
|
|
151
308
|
constructor(agent,
|
|
152
309
|
// Injectable for tests; defaults to node:child_process spawn.
|
|
153
|
-
spawnFn = spawn
|
|
310
|
+
spawnFn = spawn,
|
|
311
|
+
// Optional model pin, threaded into the agent invocation.
|
|
312
|
+
model,
|
|
313
|
+
// When true, coding-agent (interactive) mode is on: instead of running the
|
|
314
|
+
// agent headlessly and re-rendering its JSON event stream, the agent is
|
|
315
|
+
// launched as a LIVE session that owns the terminal (inherited stdio). The
|
|
316
|
+
// operator works with it until they close it; its exit releases the worker.
|
|
317
|
+
// The caller is responsible for having already passed the fail-fast TTY
|
|
318
|
+
// check (assertUsableTty).
|
|
319
|
+
interactive = false,
|
|
320
|
+
// Working-tree snapshot for the interactive post-agent summary. Injectable
|
|
321
|
+
// for tests; defaults to a real `git status --porcelain`. Only the
|
|
322
|
+
// interactive path reads it (before open() + after a clean exit).
|
|
323
|
+
gitStatus = realGitStatus,
|
|
324
|
+
// Reads the claimed task's current server-side status for the summary.
|
|
325
|
+
// Optional: when absent (e.g. the existing interactive unit tests that
|
|
326
|
+
// construct the runner with no client) the summary falls back to status
|
|
327
|
+
// `unknown` without warning — only a real fetch FAILURE warns. Wired to the
|
|
328
|
+
// real client in `workCommand`.
|
|
329
|
+
fetchTaskStatus) {
|
|
154
330
|
this.agent = agent;
|
|
155
331
|
this.spawnFn = spawnFn;
|
|
332
|
+
this.model = model;
|
|
333
|
+
this.interactive = interactive;
|
|
334
|
+
this.gitStatus = gitStatus;
|
|
335
|
+
this.fetchTaskStatus = fetchTaskStatus;
|
|
156
336
|
this.agentName = agent;
|
|
157
337
|
}
|
|
158
338
|
run(task) {
|
|
159
|
-
|
|
339
|
+
return this.interactive ? this.runInteractive(task) : this.runHeadless(task);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Default (non-interactive) path — UNCHANGED behavior: spawn the agent
|
|
343
|
+
* headlessly, pipe + re-render its JSON event stream, await its exit.
|
|
344
|
+
*/
|
|
345
|
+
runHeadless(task) {
|
|
346
|
+
const { command, args } = buildAgentCommand(this.agent, task, this.model);
|
|
160
347
|
const renderer = AGENT_RENDERER[this.agent];
|
|
161
348
|
return new Promise((resolve, reject) => {
|
|
162
349
|
// Pipe the agent's stdout so we can re-render its JSON event stream into a
|
|
@@ -167,16 +354,13 @@ export class AgentRunner {
|
|
|
167
354
|
cwd: process.cwd(),
|
|
168
355
|
stdio: ["ignore", "pipe", "inherit"],
|
|
169
356
|
});
|
|
170
|
-
// Render stdout line-by-line
|
|
171
|
-
//
|
|
357
|
+
// Render stdout line-by-line to the user's stdout so the work session
|
|
358
|
+
// reads like the agent's own interactive session.
|
|
172
359
|
const splitter = new LineSplitter();
|
|
173
|
-
const emit = (line) => {
|
|
174
|
-
process.stdout.write(`${line}\n`);
|
|
175
|
-
};
|
|
176
360
|
const renderLines = (lines) => {
|
|
177
361
|
for (const raw of lines) {
|
|
178
362
|
for (const out of renderer(raw))
|
|
179
|
-
|
|
363
|
+
process.stdout.write(`${out}\n`);
|
|
180
364
|
}
|
|
181
365
|
};
|
|
182
366
|
child.stdout?.setEncoding("utf-8");
|
|
@@ -186,30 +370,125 @@ export class AgentRunner {
|
|
|
186
370
|
child.stdout?.on("end", () => {
|
|
187
371
|
renderLines(splitter.flush());
|
|
188
372
|
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
373
|
+
this.wireChild(child, command, task, resolve, reject);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Coding-agent (interactive) path. Launch the agent as a LIVE session via
|
|
378
|
+
* `InteractiveAgent`: inherited stdio so the agent's own TUI owns the terminal
|
|
379
|
+
* (no piping, no re-render — and therefore no raw text dumping), seeded with
|
|
380
|
+
* the spec handoff. The operator works with it; when they close the agent it
|
|
381
|
+
* exits, which resolves this run and RELEASES THE WORKER so the loop claims
|
|
382
|
+
* the next task. A clean operator exit (code 0) is success; a crash/non-zero
|
|
383
|
+
* exit is still a loud failure.
|
|
384
|
+
*/
|
|
385
|
+
runInteractive(task) {
|
|
386
|
+
const { command, args } = buildInteractiveAgentCommand(this.agent, task, this.model);
|
|
387
|
+
// A fresh session per task (the daemon reuses one runner across tasks, so
|
|
388
|
+
// the single-operator InteractiveAgent instance must not be shared).
|
|
389
|
+
const session = new InteractiveAgent(command, args, this.spawnFn);
|
|
390
|
+
// Snapshot the working tree BEFORE the agent runs so we can diff it against
|
|
391
|
+
// the post-session state and report which files the agent touched.
|
|
392
|
+
const before = this.gitStatus();
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
let child;
|
|
395
|
+
try {
|
|
396
|
+
// open() enforces the single-operator invariant and local-only spawn
|
|
397
|
+
// options, then spawns the agent. A throw here (e.g. local-option guard)
|
|
398
|
+
// means nothing was spawned, so there is no child to orphan.
|
|
399
|
+
child = session.open();
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
reject(err instanceof Error ? err : new CliError(String(err)));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// On a CLEAN exit, print the one-line post-agent summary BEFORE releasing
|
|
406
|
+
// the worker (the real `resolve` fires only inside summarizeThenRelease).
|
|
407
|
+
// `wireChild`'s exit handler stays synchronous (shared with the headless
|
|
408
|
+
// path); we hand it a wrapper that defers the resolve until the summary is
|
|
409
|
+
// written. A non-zero exit still rejects through `wireChild` unchanged.
|
|
410
|
+
const releaseAfterSummary = () => {
|
|
411
|
+
void this.summarizeThenRelease(task, before, resolve);
|
|
412
|
+
};
|
|
413
|
+
this.wireChild(child, command, task, releaseAfterSummary, reject);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Print the ONE-LINE post-agent change summary, then RESOLVE (releasing the
|
|
418
|
+
* worker) — in that order, per the spec constraint "Summary before worker
|
|
419
|
+
* release". Computes the touched-file set from the before/after porcelain
|
|
420
|
+
* snapshots and reads the claimed task's current server-side status.
|
|
421
|
+
*
|
|
422
|
+
* Robustness: this runs after a CLEAN agent exit, so it must NEVER turn that
|
|
423
|
+
* success into a failure. The whole body is wrapped so `resolve()` always
|
|
424
|
+
* fires even if the summary work throws. The status read is the one sanctioned
|
|
425
|
+
* soft-fallback — a fetch failure warns and reports `unknown` rather than
|
|
426
|
+
* blocking the release; an absent status fetcher (no client wired, e.g. unit
|
|
427
|
+
* tests) is silently `unknown`.
|
|
428
|
+
*/
|
|
429
|
+
async summarizeThenRelease(task, before, resolve) {
|
|
430
|
+
try {
|
|
431
|
+
const after = this.gitStatus();
|
|
432
|
+
const filesTouched = diffTouchedFiles(before, after);
|
|
433
|
+
let status = "unknown";
|
|
434
|
+
if (this.fetchTaskStatus) {
|
|
435
|
+
try {
|
|
436
|
+
status = await this.fetchTaskStatus(task);
|
|
198
437
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (code === 0) {
|
|
206
|
-
resolve();
|
|
207
|
-
return;
|
|
438
|
+
catch (err) {
|
|
439
|
+
// The status read must not block worker release — warn and fall back
|
|
440
|
+
// to `unknown` (the one allowed soft-fallback) instead of throwing.
|
|
441
|
+
log.warn(`Could not read task ${shortId(task.id)} status for summary`, {
|
|
442
|
+
error: err instanceof Error ? err.message : String(err),
|
|
443
|
+
});
|
|
208
444
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
445
|
+
}
|
|
446
|
+
process.stdout.write(`${postAgentSummary({
|
|
447
|
+
filesTouched,
|
|
448
|
+
status,
|
|
449
|
+
shortTaskId: shortId(task.id),
|
|
450
|
+
})}\n`);
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
// A summary failure must never convert a clean agent exit into a failure;
|
|
454
|
+
// warn and still release the worker below.
|
|
455
|
+
log.warn(`Could not print post-agent summary for task ${shortId(task.id)}`, {
|
|
456
|
+
error: err instanceof Error ? err.message : String(err),
|
|
212
457
|
});
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
resolve();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Shared exit/error handling for both paths: ENOENT → install hint, non-zero
|
|
465
|
+
* exit → loud failure, code 0 → success (releases the worker). `settled`
|
|
466
|
+
* guards against a double-settle if error and exit both fire.
|
|
467
|
+
*/
|
|
468
|
+
wireChild(child, command, task, resolve, reject) {
|
|
469
|
+
let settled = false;
|
|
470
|
+
child.on("error", (err) => {
|
|
471
|
+
if (settled)
|
|
472
|
+
return;
|
|
473
|
+
settled = true;
|
|
474
|
+
if (err.code === "ENOENT") {
|
|
475
|
+
reject(new CliError(`Could not launch agent "${this.agent}": \`${command}\` was not found on PATH.\n\n` +
|
|
476
|
+
AGENT_INSTALL_HINT[this.agent]));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
reject(new CliError(`Failed to launch agent "${this.agent}" (\`${command}\`): ${err.message}`));
|
|
480
|
+
});
|
|
481
|
+
child.on("exit", (code, signal) => {
|
|
482
|
+
if (settled)
|
|
483
|
+
return;
|
|
484
|
+
settled = true;
|
|
485
|
+
if (code === 0) {
|
|
486
|
+
resolve();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const detail = code !== null ? `exited with code ${code}` : `was terminated by signal ${signal}`;
|
|
490
|
+
// Fail loud — a non-zero/aborted agent run is a FAILURE, never success.
|
|
491
|
+
reject(new CliError(`Agent "${this.agent}" (\`${command}\`) ${detail} while running task ${shortId(task.id)}.`));
|
|
213
492
|
});
|
|
214
493
|
}
|
|
215
494
|
}
|
|
@@ -220,13 +499,15 @@ export class AgentRunner {
|
|
|
220
499
|
*/
|
|
221
500
|
export class DryRunRunner {
|
|
222
501
|
agent;
|
|
502
|
+
model;
|
|
223
503
|
agentName;
|
|
224
|
-
constructor(agent) {
|
|
504
|
+
constructor(agent, model) {
|
|
225
505
|
this.agent = agent;
|
|
506
|
+
this.model = model;
|
|
226
507
|
this.agentName = agent;
|
|
227
508
|
}
|
|
228
509
|
run(task) {
|
|
229
|
-
const { command, args } = buildAgentCommand(this.agent, task);
|
|
510
|
+
const { command, args } = buildAgentCommand(this.agent, task, this.model);
|
|
230
511
|
log.info(`[dry-run] would spawn agent "${this.agent}" for task ${shortId(task.id)}`);
|
|
231
512
|
log.plain(` cwd: ${process.cwd()}`);
|
|
232
513
|
log.plain(` command: ${command} ${formatDryRunArgs(args)}`);
|
|
@@ -318,9 +599,11 @@ function parseWorkArgs(argv) {
|
|
|
318
599
|
args: argv,
|
|
319
600
|
options: {
|
|
320
601
|
agent: { type: "string" },
|
|
602
|
+
model: { type: "string" },
|
|
321
603
|
interval: { type: "string" },
|
|
322
604
|
once: { type: "boolean" },
|
|
323
605
|
"dry-run": { type: "boolean" },
|
|
606
|
+
interactive: { type: "boolean" },
|
|
324
607
|
},
|
|
325
608
|
strict: true,
|
|
326
609
|
}));
|
|
@@ -336,6 +619,17 @@ function parseWorkArgs(argv) {
|
|
|
336
619
|
if (!SUPPORTED_AGENTS.includes(agent)) {
|
|
337
620
|
throw new CliError(`Unsupported agent "${agent}".\n\nSupported agents: ${SUPPORTED_AGENTS.join(", ")}`);
|
|
338
621
|
}
|
|
622
|
+
// --model is optional: when omitted the agent uses its own configured
|
|
623
|
+
// default. But an explicit empty `--model ""` is a mistake we fail loud on
|
|
624
|
+
// rather than silently passing a blank model through to the agent.
|
|
625
|
+
const rawModel = values["model"];
|
|
626
|
+
let model;
|
|
627
|
+
if (rawModel !== undefined) {
|
|
628
|
+
if (rawModel.trim() === "") {
|
|
629
|
+
throw new CliError(`Invalid --model "${rawModel}". Provide a model alias (e.g. opus, sonnet) or full model id.`);
|
|
630
|
+
}
|
|
631
|
+
model = rawModel;
|
|
632
|
+
}
|
|
339
633
|
let intervalSeconds = DEFAULT_INTERVAL_SECONDS;
|
|
340
634
|
const rawInterval = values["interval"];
|
|
341
635
|
if (rawInterval !== undefined) {
|
|
@@ -353,11 +647,25 @@ function parseWorkArgs(argv) {
|
|
|
353
647
|
// Validated above against SUPPORTED_AGENTS; the readonly-tuple `includes`
|
|
354
648
|
// check above does not narrow `string`, so assert the proven type here.
|
|
355
649
|
agent: agent,
|
|
650
|
+
model,
|
|
356
651
|
intervalMs: intervalSeconds * 1000,
|
|
357
652
|
once: values["once"] === true,
|
|
358
653
|
dryRun: values["dry-run"] === true,
|
|
654
|
+
interactive: values["interactive"] === true,
|
|
359
655
|
};
|
|
360
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* Resolve whether interactive mode is on. Opt-in is explicit by design (the
|
|
659
|
+
* spec keeps existing runs non-interactive unless enabled): the `--interactive`
|
|
660
|
+
* flag, or `interactive: true` in `.zenorm.json`. The flag wins; config only
|
|
661
|
+
* supplies the default when the flag is absent. A bad config value already
|
|
662
|
+
* threw in `validateConfig`, so this never silently coerces.
|
|
663
|
+
*/
|
|
664
|
+
export function resolveInteractive(flagPassed, config) {
|
|
665
|
+
if (flagPassed)
|
|
666
|
+
return true;
|
|
667
|
+
return config?.interactive === true;
|
|
668
|
+
}
|
|
361
669
|
/** Write daemon-only meta output (e.g. the dry-run banner) to stderr. */
|
|
362
670
|
function writeDaemonLine(line) {
|
|
363
671
|
process.stderr.write(`${line}\n`);
|
|
@@ -372,6 +680,22 @@ async function releaseTaskToTodo(client, task) {
|
|
|
372
680
|
});
|
|
373
681
|
}
|
|
374
682
|
}
|
|
683
|
+
/**
|
|
684
|
+
* Read the claimed task's CURRENT server-side status for the interactive
|
|
685
|
+
* post-agent summary. `GET /v1/specs/:specId/tasks` returns the spec's tasks;
|
|
686
|
+
* we find this one by id and return its status. Throws if the task is missing
|
|
687
|
+
* from the response (fail loud rather than masking a wrong/empty list) — the
|
|
688
|
+
* caller (`summarizeThenRelease`) catches and degrades to `unknown` so a status
|
|
689
|
+
* read never blocks worker release.
|
|
690
|
+
*/
|
|
691
|
+
async function fetchTaskStatus(client, task) {
|
|
692
|
+
const { tasks } = await client.get(`/v1/specs/${task.specId}/tasks`);
|
|
693
|
+
const found = tasks.find((t) => t.id === task.id);
|
|
694
|
+
if (!found) {
|
|
695
|
+
throw new CliError(`Task ${shortId(task.id)} not found in spec ${task.specId} tasks list.`);
|
|
696
|
+
}
|
|
697
|
+
return found.status;
|
|
698
|
+
}
|
|
375
699
|
async function heartbeatTask(client, task) {
|
|
376
700
|
try {
|
|
377
701
|
await client.post(`/v1/tasks/${task.id}/heartbeat`, {});
|
|
@@ -517,16 +841,42 @@ export async function workCommand(argv, deps) {
|
|
|
517
841
|
// Fatal (CliError) failures — bad args, not a git repo — abort BEFORE the
|
|
518
842
|
// loop starts so they surface immediately rather than being swallowed as a
|
|
519
843
|
// transient blip.
|
|
520
|
-
const { agent, intervalMs, once, dryRun } = parseWorkArgs(argv);
|
|
844
|
+
const { agent, model, intervalMs, once, dryRun, interactive: interactiveFlag } = parseWorkArgs(argv);
|
|
521
845
|
const repo = resolveCurrentRepo();
|
|
846
|
+
// Resolve the interactive opt-in (flag OR project config). `loadConfig` walks
|
|
847
|
+
// up from cwd for `.zenorm.json`; a malformed config throws (no masked errors).
|
|
848
|
+
const interactive = resolveInteractive(interactiveFlag, loadConfig());
|
|
849
|
+
if (interactive && dryRun) {
|
|
850
|
+
// --dry-run never spawns an agent, so there is no live agent session to drop
|
|
851
|
+
// into. Refuse the combination loudly rather than silently ignoring one.
|
|
852
|
+
throw new CliError("`--interactive` cannot be combined with `--dry-run`: dry-run never " +
|
|
853
|
+
"launches an agent, so there is no interactive coding-agent session to open.");
|
|
854
|
+
}
|
|
855
|
+
// Fail-fast gate (spec: "Fail-fast without usable TTY"). Done BEFORE the loop
|
|
856
|
+
// and BEFORE any claim so a misconfigured interactive run errors immediately
|
|
857
|
+
// instead of claiming a task and then hanging. Non-interactive runs skip this
|
|
858
|
+
// entirely — they never require a TTY and never prompt.
|
|
859
|
+
if (interactive) {
|
|
860
|
+
assertUsableTty(deps?.ttyProbe ?? processTtyProbe());
|
|
861
|
+
}
|
|
522
862
|
const client = deps?.client ?? new ZenormClient();
|
|
523
|
-
const runner = deps?.runner ??
|
|
863
|
+
const runner = deps?.runner ??
|
|
864
|
+
(dryRun
|
|
865
|
+
? new DryRunRunner(agent, model)
|
|
866
|
+
: new AgentRunner(agent, spawn, model, interactive,
|
|
867
|
+
// Real working-tree snapshot for the interactive post-agent summary.
|
|
868
|
+
realGitStatus,
|
|
869
|
+
// Real status read: list the spec's tasks and pick out this task's
|
|
870
|
+
// current status (it is likely `done` once the agent inside the
|
|
871
|
+
// session ran `zenorm task complete`).
|
|
872
|
+
(task) => fetchTaskStatus(client, task)));
|
|
524
873
|
// Startup banner — shown on EVERY run (not just --dry-run) so a real session
|
|
525
874
|
// announces what it's claiming for instead of sitting silent until the first
|
|
526
875
|
// task lands. Written to stderr so it never interleaves with the agent
|
|
527
876
|
// transcript when stdout is piped to a file.
|
|
528
877
|
writeDaemonLine(daemonBanner({
|
|
529
878
|
agent,
|
|
879
|
+
model,
|
|
530
880
|
repo: `${repo.owner}/${repo.name}`,
|
|
531
881
|
intervalSeconds: intervalMs / 1000,
|
|
532
882
|
once,
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
export interface ZenormConfig {
|
|
2
2
|
org?: string;
|
|
3
3
|
apiUrl?: string;
|
|
4
|
+
/**
|
|
5
|
+
* Opt into coding-agent mode for local `zenorm work` runs: instead of running
|
|
6
|
+
* each task headlessly, launch the active coding agent (the one named by
|
|
7
|
+
* `--agent`) as a LIVE interactive session seeded with the spec handoff. The
|
|
8
|
+
* operator works with the agent, then closes it to release the worker and
|
|
9
|
+
* continue to the next task. Off by default; the `--interactive` flag
|
|
10
|
+
* overrides this. Requires a usable local TTY at run time (fails loud
|
|
11
|
+
* otherwise — see `interactive-agent.ts`).
|
|
12
|
+
*/
|
|
13
|
+
interactive?: boolean;
|
|
4
14
|
}
|
|
5
15
|
export declare function validateConfig(raw: unknown): ZenormConfig;
|
|
6
16
|
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,GAAG,YAAY,CA8BzD"}
|
package/dist/config/schema.js
CHANGED
|
@@ -17,5 +17,11 @@ export function validateConfig(raw) {
|
|
|
17
17
|
}
|
|
18
18
|
config.apiUrl = obj["apiUrl"];
|
|
19
19
|
}
|
|
20
|
+
if ("interactive" in obj) {
|
|
21
|
+
if (typeof obj["interactive"] !== "boolean") {
|
|
22
|
+
throw new CliError('.zenorm.json: "interactive" must be a boolean');
|
|
23
|
+
}
|
|
24
|
+
config.interactive = obj["interactive"];
|
|
25
|
+
}
|
|
20
26
|
return config;
|
|
21
27
|
}
|
package/dist/util/markdown.d.ts
CHANGED
|
@@ -21,4 +21,20 @@ export type SpecBlock = {
|
|
|
21
21
|
* the agent sees a clean "## goals", "## acceptance_criterion" structure.
|
|
22
22
|
*/
|
|
23
23
|
export declare function blocksToMarkdown(blocks: SpecBlock[]): string;
|
|
24
|
+
export type AttachmentRecord = {
|
|
25
|
+
id: string;
|
|
26
|
+
fileName: string;
|
|
27
|
+
fileSize: number;
|
|
28
|
+
mimeType: string;
|
|
29
|
+
status: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Render the spec's ready attachments as an "## Attachments" markdown section
|
|
33
|
+
* for the worker work context. Lists fileName — mimeType — pretty size only;
|
|
34
|
+
* the file CONTENT/bytes are never inlined (binary can't be, and no AC needs
|
|
35
|
+
* download). Callers must pass ONLY ready attachments — the server is the gate
|
|
36
|
+
* (the `?status=ready` filter), this renderer does not re-filter. Returns an
|
|
37
|
+
* empty string when there are none so `pull` can omit the section.
|
|
38
|
+
*/
|
|
39
|
+
export declare function attachmentsToMarkdown(attachments: AttachmentRecord[]): string;
|
|
24
40
|
//# sourceMappingURL=markdown.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/util/markdown.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAmB1D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,MAAM,CAqB1E;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACvC,CAAC;AAEF;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,CA+B5D"}
|
|
1
|
+
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/util/markdown.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAmB1D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,MAAM,CAqB1E;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACvC,CAAC;AAEF;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,CA+B5D;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAmBF;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,gBAAgB,EAAE,GAAG,MAAM,CAO7E"}
|
package/dist/util/markdown.js
CHANGED
|
@@ -77,6 +77,40 @@ export function blocksToMarkdown(blocks) {
|
|
|
77
77
|
}
|
|
78
78
|
return out.join("\n").trimEnd();
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Render a human-readable byte size (e.g. 1536 → "1.5 KB"). Used for the
|
|
82
|
+
* attachments listing only — purely cosmetic.
|
|
83
|
+
*/
|
|
84
|
+
function prettySize(bytes) {
|
|
85
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
86
|
+
return `${bytes} B`;
|
|
87
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
88
|
+
let value = bytes;
|
|
89
|
+
let unit = 0;
|
|
90
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
91
|
+
value /= 1024;
|
|
92
|
+
unit += 1;
|
|
93
|
+
}
|
|
94
|
+
const rounded = unit === 0 ? value : Math.round(value * 10) / 10;
|
|
95
|
+
return `${rounded} ${units[unit]}`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Render the spec's ready attachments as an "## Attachments" markdown section
|
|
99
|
+
* for the worker work context. Lists fileName — mimeType — pretty size only;
|
|
100
|
+
* the file CONTENT/bytes are never inlined (binary can't be, and no AC needs
|
|
101
|
+
* download). Callers must pass ONLY ready attachments — the server is the gate
|
|
102
|
+
* (the `?status=ready` filter), this renderer does not re-filter. Returns an
|
|
103
|
+
* empty string when there are none so `pull` can omit the section.
|
|
104
|
+
*/
|
|
105
|
+
export function attachmentsToMarkdown(attachments) {
|
|
106
|
+
if (attachments.length === 0)
|
|
107
|
+
return "";
|
|
108
|
+
const out = ["## Attachments", ""];
|
|
109
|
+
for (const a of attachments) {
|
|
110
|
+
out.push(`- ${a.fileName} — ${a.mimeType} — ${prettySize(a.fileSize)}`);
|
|
111
|
+
}
|
|
112
|
+
return out.join("\n");
|
|
113
|
+
}
|
|
80
114
|
function humanizeType(type) {
|
|
81
115
|
return type.split("_").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
|
|
82
116
|
}
|