@gigaai/newton 0.3.0 → 0.4.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/README.md CHANGED
@@ -66,14 +66,21 @@ script body. The body runs as an async function with these in scope:
66
66
  | `pipeline(items, ...stages)` | Each item flows through the stages independently — no barrier between stages. Stage signature: `(prev, item, index)`. |
67
67
  | `phase(title)` | Group subsequent agents in progress output. |
68
68
  | `log(message)` | Narrator line in progress output and the journal. |
69
+ | `workflow(nameOrPath, args?)` | Run one child workflow. Bare names resolve from `.newton/workflows/<name>.js`; paths resolve relative to the calling workflow. Nesting is limited to one child level. |
69
70
  | `args` | Whatever you passed via `--args` (JSON-parsed when possible). |
70
71
  | `budget` | `{ total, spent(), remaining() }` for the `--budget` output-token ceiling. |
71
72
 
72
73
  `agent()` options: `provider` (see below), `model`, `effort` (`low`–`xhigh`),
73
74
  `access` (`read` | `write` | `full` — sandbox posture), `schema` (JSON Schema
74
75
  for structured output, validated with automatic retries), `label`, `phase`,
75
- `cwd`, `timeoutMs`, and `command` an escape hatch to run any ACP agent,
76
- e.g. `command: ['my-agent', '--acp']`.
76
+ `cwd`, `timeoutMs`, `isolation: 'worktree'` for disposable per-agent git
77
+ worktrees, `mcp` (`true` for all `.mcp.json` servers, or a list of names/inline
78
+ servers), `config` (ACP `configId` to string value), and `command` — an escape
79
+ hatch to run any ACP agent, e.g. `command: ['my-agent', '--acp']`.
80
+
81
+ Workflow scripts run in a sandboxed JavaScript realm: Node globals such as
82
+ `process`, `fetch`, and `setTimeout` are not available, and `console.log()`
83
+ routes through `log()` into progress output and the journal.
77
84
 
78
85
  ## Providers
79
86
 
@@ -98,7 +105,7 @@ the agent advertises over ACP (session config options) and selects it
98
105
  protocol-side. `newton models <provider>` shows what an agent exposes;
99
106
  requesting a model an agent doesn't offer fails with the available list.
100
107
 
101
- `Date.now()`, argless `new Date()`, and `Math.random()` throw inside
108
+ `Date.now()`, argless `new Date()`, and `Math.random()` also throw inside
102
109
  workflows: nondeterminism would silently break `--resume` replay. Pass
103
110
  timestamps in via `--args`.
104
111
 
@@ -113,15 +120,32 @@ workspace:
113
120
  chunk, tool call, and permission decision.
114
121
 
115
122
  `newton run workflow.js --resume <runId>` replays successful agent results
116
- from a previous run's journal identical calls return instantly and only new
117
- or changed calls hit real agents.
123
+ from a previous run's journal. The default `--resume-mode prefix` replays by
124
+ call position until the first changed agent call, then runs that call and
125
+ everything after it live. `--resume-mode hash` uses the legacy hash replay:
126
+ identical calls return instantly no matter where they appear.
127
+
128
+ Long runs can be detached and controlled from another shell:
129
+
130
+ - `newton run workflow.js --detach` starts in the background and prints
131
+ `{ runId, pid }`.
132
+ - `newton runs [--json]` lists `.newton/runs/*` with status, agent count,
133
+ output tokens, and pid.
134
+ - `newton watch <runId> [--json]` replays the journal from the beginning and
135
+ keeps tailing until the run ends.
136
+ - `newton skip <runId> <agentId>` asks a queued or live agent to cancel;
137
+ `agent()` returns `null` and the workflow continues.
118
138
 
119
139
  ## CLI
120
140
 
121
141
  ```
122
142
  newton run <workflow.js> --args <json> --cwd <dir> --resume <runId> --json
143
+ --resume-mode <prefix|hash> --detach
123
144
  --budget <tokens> --max-agents <n> --timeout <seconds>
124
145
  --concurrency <n>
146
+ newton runs [--json] list runs under .newton/runs
147
+ newton watch <runId> [--json] replay/tail a run's journal
148
+ newton skip <runId> <agentId> cancel one agent; workflow continues
125
149
  newton validate <workflow.js> parse + compile without spawning agents
126
150
  newton agents [--json] supported agents: installed? signed in? (alias: doctor)
127
151
  newton models <provider> ask an agent which models/modes it exposes
package/dist/acp.js CHANGED
@@ -50,8 +50,14 @@ export class AcpAgentSession {
50
50
  toolCalls = 0;
51
51
  usage = null;
52
52
  stderrTail = [];
53
+ aborted;
54
+ abortSignal;
55
+ resolveAbortSignal;
53
56
  constructor(request) {
54
57
  this.request = request;
58
+ this.abortSignal = new Promise((resolveAbort) => {
59
+ this.resolveAbortSignal = resolveAbort;
60
+ });
55
61
  }
56
62
  async prompt() {
57
63
  try {
@@ -72,6 +78,15 @@ export class AcpAgentSession {
72
78
  }
73
79
  async promptAgain(prompt) {
74
80
  try {
81
+ if (this.aborted) {
82
+ return {
83
+ text: this.text,
84
+ stopReason: "cancelled",
85
+ usage: this.usage,
86
+ toolCalls: this.toolCalls,
87
+ error: this.aborted,
88
+ };
89
+ }
75
90
  const connection = this.connection;
76
91
  const sessionId = this.sessionId;
77
92
  this.text = "";
@@ -84,7 +99,7 @@ export class AcpAgentSession {
84
99
  sessionId,
85
100
  prompt: [{ type: "text", text: prompt }],
86
101
  });
87
- const outcome = await Promise.race([turn, timedOut]);
102
+ const outcome = await Promise.race([turn, timedOut, this.abortSignal]);
88
103
  clearTimeout(timer);
89
104
  if (outcome === "timeout") {
90
105
  await connection.cancel({ sessionId }).catch(() => { });
@@ -99,6 +114,19 @@ export class AcpAgentSession {
99
114
  error: `agent timed out after ${this.request.timeoutMs}ms`,
100
115
  };
101
116
  }
117
+ if (outcome === "aborted") {
118
+ await connection.cancel({ sessionId }).catch(() => { });
119
+ // Give the agent a moment to flush final updates, then hard-stop.
120
+ await Promise.race([turn.catch(() => { }), new Promise((r) => setTimeout(r, 3000))]);
121
+ this.close();
122
+ return {
123
+ text: this.text,
124
+ stopReason: "cancelled",
125
+ usage: this.usage,
126
+ toolCalls: this.toolCalls,
127
+ error: this.aborted,
128
+ };
129
+ }
102
130
  this.usage = outcome.usage ?? this.usage;
103
131
  return {
104
132
  text: this.text,
@@ -111,6 +139,10 @@ export class AcpAgentSession {
111
139
  return this.failure(error);
112
140
  }
113
141
  }
142
+ abort(reason) {
143
+ this.aborted = reason;
144
+ this.resolveAbortSignal("aborted");
145
+ }
114
146
  close() {
115
147
  if (this.child && !this.child.killed)
116
148
  this.child.kill("SIGTERM");
@@ -187,13 +219,17 @@ export class AcpAgentSession {
187
219
  session: { configOptions: {} },
188
220
  },
189
221
  });
190
- const session = await connection.newSession({ cwd: resolve(cwd), mcpServers: [] });
222
+ const session = await connection.newSession({ cwd: resolve(cwd), mcpServers: this.request.mcpServers ?? [] });
191
223
  this.sessionId = session.sessionId;
192
224
  this.sessionInfo = session;
193
225
  trace({ type: "session", sessionId: session.sessionId, configOptions: session.configOptions ?? null });
194
226
  if (this.request.spec.model && !resolved.modelHandled) {
195
227
  await this.selectModelViaProtocol(session, this.request.spec.model);
196
228
  }
229
+ for (const [configId, value] of Object.entries(this.request.config ?? {})) {
230
+ await connection.setSessionConfigOption({ sessionId: this.sessionId, configId, value });
231
+ trace({ type: "config_set", configId, value });
232
+ }
197
233
  }
198
234
  /**
199
235
  * Providers without a CLI/env model knob may still expose model choice as an
package/dist/cli.js CHANGED
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { accessSync, constants } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { accessSync, closeSync, constants, mkdirSync, openSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
3
5
  import { AcpAgentSession } from "./acp.js";
6
+ import { listRuns, readStatus, requestSkip, tailJournal } from "./control.js";
4
7
  import { compileWorkflow, loadWorkflow, runWorkflow } from "./executor.js";
8
+ import { newRunId } from "./journal.js";
5
9
  import { HumanReporter, JsonReporter } from "./progress.js";
6
10
  import { describeProviders } from "./providers.js";
7
11
  import { VERSION } from "./version.js";
@@ -9,6 +13,10 @@ const USAGE = `newton — deterministic multi-agent workflows over ACP
9
13
 
10
14
  Usage:
11
15
  newton run <workflow.js> [options] Run a workflow in the current directory
16
+ newton run <workflow.js> --detach Start in the background; prints { runId, pid }
17
+ newton runs [--json] List runs under .newton/runs with status
18
+ newton watch <runId> [--json] Tail a run's journal as live progress
19
+ newton skip <runId> <agentId> Cancel one agent; agent() returns null, run continues
12
20
  newton validate <workflow.js> Parse + compile a workflow without running agents
13
21
  newton agents [--json] Supported agents: installed? signed in? (alias: doctor)
14
22
  newton models <provider> [--json] Ask an agent which models it exposes
@@ -17,6 +25,8 @@ Run options:
17
25
  --args <json> Value exposed to the script as \`args\`
18
26
  --cwd <dir> Workspace agents operate in (default: current directory)
19
27
  --resume <runId> Replay cached agent results from a previous run
28
+ --resume-mode <prefix|hash> Resume matching: positional prefix (default) or hash replay
29
+ --detach Start the run in the background
20
30
  --json NDJSON events on stdout instead of human output
21
31
  --budget <tokens> Output-token ceiling; agent() throws once exhausted
22
32
  --max-agents <n> Agent-call cap (default 200)
@@ -28,7 +38,11 @@ followed by a script body using agent()/parallel()/pipeline()/phase()/log(),
28
38
  with \`args\` and \`budget\` in scope. agent() opts: provider (codex | claude |
29
39
  gemini | qwen | cursor | opencode | goose | copilot), model, effort, access
30
40
  (read | write | full), schema, label, phase, cwd, command (any ACP agent,
31
- e.g. ["my-agent", "--acp"]).
41
+ e.g. ["my-agent", "--acp"]), isolation ('worktree'), mcp (true | names/servers
42
+ from .mcp.json), config ({configId: value}).
43
+ Workflows may call workflow(nameOrPath, args) one level deep; bare names resolve
44
+ from .newton/workflows/<name>.js.
45
+ Scripts run sandboxed: no process/fetch/setTimeout; console.log routes to log().
32
46
 
33
47
  Runs are recorded under .newton/runs/<runId>/ — journal.jsonl plus a full
34
48
  per-agent ACP trace.`;
@@ -66,10 +80,47 @@ function intFlag(flags, name) {
66
80
  fail(`--${name} requires a positive integer`);
67
81
  return value;
68
82
  }
83
+ function resumeModeFlag(flags) {
84
+ const raw = flags["resume-mode"];
85
+ if (raw === undefined)
86
+ return undefined;
87
+ if (raw === "prefix" || raw === "hash")
88
+ return raw;
89
+ fail("--resume-mode requires prefix or hash");
90
+ }
91
+ function commandRunDetached(file, flags) {
92
+ const cwd = typeof flags.cwd === "string" ? resolve(flags.cwd) : process.cwd();
93
+ const runId = newRunId();
94
+ const runDir = join(cwd, ".newton", "runs", runId);
95
+ mkdirSync(runDir, { recursive: true });
96
+ const fd = openSync(join(runDir, "detach.log"), "a");
97
+ const argv = [process.argv[1], "run", resolve(file), "--json", "--run-id", runId, "--cwd", cwd];
98
+ for (const name of ["args", "resume", "resume-mode", "budget", "max-agents", "timeout", "concurrency"]) {
99
+ const value = flags[name];
100
+ if (typeof value === "string")
101
+ argv.push(`--${name}`, value);
102
+ }
103
+ let child;
104
+ try {
105
+ child = spawn(process.execPath, argv, { detached: true, stdio: ["ignore", fd, fd] });
106
+ }
107
+ finally {
108
+ closeSync(fd);
109
+ }
110
+ if (!child)
111
+ fail("failed to start detached run");
112
+ child.unref();
113
+ process.stdout.write(`${JSON.stringify({ runId, pid: child.pid }, null, 2)}\n`);
114
+ process.exit(0);
115
+ }
69
116
  async function commandRun(positional, flags) {
70
117
  const file = positional[0];
71
118
  if (!file)
72
119
  fail("newton run requires a workflow file");
120
+ const resumeMode = resumeModeFlag(flags);
121
+ if (flags.detach === true) {
122
+ commandRunDetached(file, flags);
123
+ }
73
124
  let args;
74
125
  if (typeof flags.args === "string") {
75
126
  try {
@@ -87,6 +138,9 @@ async function commandRun(positional, flags) {
87
138
  args,
88
139
  cwd: typeof flags.cwd === "string" ? flags.cwd : process.cwd(),
89
140
  resumeRunId: typeof flags.resume === "string" ? flags.resume : undefined,
141
+ resumeMode,
142
+ runId: typeof flags["run-id"] === "string" ? flags["run-id"] : undefined,
143
+ detached: typeof flags["run-id"] === "string",
90
144
  budgetTokens: intFlag(flags, "budget"),
91
145
  maxAgents: intFlag(flags, "max-agents"),
92
146
  agentTimeoutMs: timeoutSeconds ? timeoutSeconds * 1000 : undefined,
@@ -99,6 +153,126 @@ async function commandRun(positional, flags) {
99
153
  }
100
154
  process.exit(result.status === "ok" ? 0 : 1);
101
155
  }
156
+ function commandRuns(flags) {
157
+ const workspace = process.cwd();
158
+ const rows = listRuns(workspace);
159
+ if (flags.json === true) {
160
+ process.stdout.write(`${JSON.stringify(rows.map(({ runId, stale, status }) => ({ runId, stale, ...(status ?? {}) })), null, 2)}\n`);
161
+ return;
162
+ }
163
+ if (rows.length === 0) {
164
+ process.stdout.write("no runs under .newton/runs\n");
165
+ return;
166
+ }
167
+ const table = [
168
+ ["runId", "status", "agents", "out-tok", "pid", "name"],
169
+ ...rows.map(({ runId, stale, status }) => [
170
+ runId,
171
+ stale ? "stale" : (status?.status ?? "—"),
172
+ status ? String(status.agents) : "",
173
+ status ? String(status.outputTokens) : "",
174
+ status ? String(status.pid) : "",
175
+ status?.name ?? "",
176
+ ]),
177
+ ];
178
+ const widths = table[0].map((_, index) => Math.max(...table.map((row) => row[index].length)));
179
+ for (const row of table) {
180
+ process.stdout.write(`${row.map((value, index) => value.padEnd(widths[index])).join(" ")}\n`);
181
+ }
182
+ }
183
+ async function commandWatch(positional, flags) {
184
+ const runId = positional[0];
185
+ if (!runId)
186
+ fail("newton watch requires a run id");
187
+ const runDir = join(process.cwd(), ".newton", "runs", runId);
188
+ try {
189
+ accessSync(runDir, constants.F_OK);
190
+ }
191
+ catch {
192
+ fail(`no run "${runId}"`);
193
+ }
194
+ const journalPath = join(runDir, "journal.jsonl");
195
+ const json = flags.json === true;
196
+ const reporter = new HumanReporter();
197
+ let startT;
198
+ let exitCode = 0;
199
+ const keepAlive = setInterval(() => { }, 1 << 30);
200
+ let handle;
201
+ handle = tailJournal(journalPath, (entry) => {
202
+ if (json) {
203
+ process.stdout.write(`${JSON.stringify(entry)}\n`);
204
+ }
205
+ else {
206
+ switch (entry.type) {
207
+ case "workflow_start":
208
+ startT = Date.parse(String(entry.t));
209
+ reporter.start({ name: String(entry.name), description: "" }, String(entry.runId), dirname(journalPath));
210
+ break;
211
+ case "phase":
212
+ reporter.phase(String(entry.title));
213
+ break;
214
+ case "log":
215
+ reporter.log(String(entry.message));
216
+ break;
217
+ case "agent_start":
218
+ reporter.agentStart(Number(entry.id), String(entry.label), entry.spec, entry.phase, false);
219
+ break;
220
+ case "agent_end": {
221
+ if (entry.cached === true) {
222
+ reporter.agentStart(Number(entry.id), String(entry.label), {}, entry.phase, true);
223
+ }
224
+ const ok = entry.status === "ok";
225
+ reporter.agentEnd(Number(entry.id), ok, Number(entry.seconds ?? 0), ok ? (entry.cached ? "cached" : `${entry.toolCalls ?? 0} tools`) : String(entry.error ?? "error"));
226
+ break;
227
+ }
228
+ case "workflow_child_start":
229
+ reporter.log(`▸ workflow ${entry.name}`);
230
+ break;
231
+ case "workflow_child_end":
232
+ reporter.log(`${entry.status === "ok" ? "✔" : "✖"} workflow ${entry.name}`);
233
+ break;
234
+ }
235
+ }
236
+ if (entry.type === "workflow_end") {
237
+ const endT = Date.parse(String(entry.t));
238
+ exitCode = entry.status === "ok" ? 0 : 1;
239
+ if (!json) {
240
+ reporter.done({
241
+ runId,
242
+ status: entry.status,
243
+ result: entry.result,
244
+ error: entry.error,
245
+ agents: Number(entry.agents ?? 0),
246
+ outputTokens: Number(entry.outputTokens ?? 0),
247
+ durationMs: startT ? Math.max(0, endT - startT) : 0,
248
+ });
249
+ }
250
+ clearInterval(keepAlive);
251
+ handle.stop();
252
+ process.exit(exitCode);
253
+ }
254
+ });
255
+ }
256
+ function commandSkip(positional) {
257
+ const runId = positional[0];
258
+ const rawAgentId = positional[1];
259
+ if (!runId || !rawAgentId)
260
+ fail("newton skip requires a run id and agent id");
261
+ const agentId = Number(rawAgentId);
262
+ if (!Number.isInteger(agentId) || agentId <= 0)
263
+ fail("agentId requires a positive integer");
264
+ try {
265
+ requestSkip(process.cwd(), runId, agentId);
266
+ }
267
+ catch (error) {
268
+ fail(error.message);
269
+ }
270
+ process.stdout.write(`skip requested: run ${runId} agent #${agentId}\n`);
271
+ const status = readStatus(join(process.cwd(), ".newton", "runs", runId));
272
+ if (status?.status !== "running") {
273
+ process.stderr.write(`warning: run ${runId} status is ${status?.status ?? "unknown"}; skip may have no effect\n`);
274
+ }
275
+ }
102
276
  function commandValidate(positional) {
103
277
  const file = positional[0];
104
278
  if (!file)
@@ -184,6 +358,15 @@ async function main() {
184
358
  case "run":
185
359
  await commandRun(positional, flags);
186
360
  break;
361
+ case "runs":
362
+ commandRuns(flags);
363
+ break;
364
+ case "watch":
365
+ await commandWatch(positional, flags);
366
+ break;
367
+ case "skip":
368
+ commandSkip(positional);
369
+ break;
187
370
  case "validate":
188
371
  try {
189
372
  commandValidate(positional);
@@ -0,0 +1,169 @@
1
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ export function writeStatus(runDir, status) {
4
+ const tmpPath = join(runDir, "status.json.tmp");
5
+ const statusPath = join(runDir, "status.json");
6
+ writeFileSync(tmpPath, `${JSON.stringify(status, null, 2)}\n`, "utf8");
7
+ renameSync(tmpPath, statusPath);
8
+ }
9
+ export function readStatus(runDir) {
10
+ try {
11
+ return JSON.parse(readFileSync(join(runDir, "status.json"), "utf8"));
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ export function pidAlive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch (error) {
23
+ return errorCode(error) === "EPERM";
24
+ }
25
+ }
26
+ export function listRuns(workspace) {
27
+ const runsDir = join(workspace, ".newton", "runs");
28
+ let runIds;
29
+ try {
30
+ runIds = readdirSync(runsDir, { withFileTypes: true })
31
+ .filter((entry) => entry.isDirectory())
32
+ .map((entry) => entry.name)
33
+ .sort()
34
+ .reverse();
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ return runIds.map((runId) => {
40
+ const status = readStatus(join(runsDir, runId));
41
+ return {
42
+ runId,
43
+ status,
44
+ stale: status?.status === "running" && !pidAlive(status.pid),
45
+ };
46
+ });
47
+ }
48
+ export function requestSkip(workspace, runId, agentId) {
49
+ const runDir = join(workspace, ".newton", "runs", runId);
50
+ try {
51
+ if (!statSync(runDir).isDirectory()) {
52
+ throw new Error("not a directory");
53
+ }
54
+ }
55
+ catch {
56
+ throw new Error(`no run "${runId}" under .newton/runs`);
57
+ }
58
+ const controlDir = join(runDir, "control");
59
+ mkdirSync(controlDir, { recursive: true });
60
+ writeFileSync(join(controlDir, `skip-${agentId}`), "", "utf8");
61
+ }
62
+ export function readSkipRequests(runDir) {
63
+ const ids = new Set();
64
+ let entries;
65
+ try {
66
+ entries = readdirSync(join(runDir, "control"));
67
+ }
68
+ catch {
69
+ return ids;
70
+ }
71
+ for (const entry of entries) {
72
+ const match = /^skip-(\d+)$/.exec(entry);
73
+ if (!match)
74
+ continue;
75
+ const id = Number(match[1]);
76
+ if (Number.isInteger(id))
77
+ ids.add(id);
78
+ }
79
+ return ids;
80
+ }
81
+ export function tailJournal(journalPath, onEntry, pollMs = 300) {
82
+ let offset = 0;
83
+ let partial = Buffer.alloc(0);
84
+ let stopped = false;
85
+ function emitLine(line) {
86
+ const text = line.toString("utf8");
87
+ if (!text.trim())
88
+ return;
89
+ let entry;
90
+ try {
91
+ entry = JSON.parse(text);
92
+ }
93
+ catch {
94
+ // Ignore incomplete/corrupt journal lines; later valid lines still stream.
95
+ return;
96
+ }
97
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
98
+ onEntry(entry);
99
+ }
100
+ }
101
+ function consume(chunk) {
102
+ const data = partial.length === 0 ? chunk : Buffer.concat([partial, chunk]);
103
+ let lineStart = 0;
104
+ for (let i = 0; i < data.length; i++) {
105
+ if (data[i] !== 10)
106
+ continue;
107
+ emitLine(data.subarray(lineStart, i));
108
+ lineStart = i + 1;
109
+ }
110
+ partial = lineStart < data.length ? data.subarray(lineStart) : Buffer.alloc(0);
111
+ }
112
+ function tick() {
113
+ if (stopped)
114
+ return;
115
+ let size;
116
+ try {
117
+ const stat = statSync(journalPath);
118
+ if (!stat.isFile())
119
+ return;
120
+ size = stat.size;
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ if (size < offset) {
126
+ offset = 0;
127
+ partial = Buffer.alloc(0);
128
+ }
129
+ if (size === offset)
130
+ return;
131
+ let fd;
132
+ try {
133
+ fd = openSync(journalPath, "r");
134
+ const length = size - offset;
135
+ const chunk = Buffer.alloc(length);
136
+ const bytesRead = readSync(fd, chunk, 0, length, offset);
137
+ if (bytesRead <= 0)
138
+ return;
139
+ offset += bytesRead;
140
+ consume(bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead));
141
+ }
142
+ catch {
143
+ return;
144
+ }
145
+ finally {
146
+ if (fd !== undefined) {
147
+ try {
148
+ closeSync(fd);
149
+ }
150
+ catch {
151
+ // A close failure should not stop journal polling.
152
+ }
153
+ }
154
+ }
155
+ }
156
+ const timer = setInterval(tick, pollMs);
157
+ timer.unref();
158
+ return {
159
+ stop() {
160
+ stopped = true;
161
+ clearInterval(timer);
162
+ },
163
+ };
164
+ }
165
+ function errorCode(error) {
166
+ return typeof error === "object" && error !== null && "code" in error
167
+ ? error.code
168
+ : undefined;
169
+ }
package/dist/executor.js CHANGED
@@ -1,11 +1,36 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { cpus } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { AcpAgentSession } from "./acp.js";
5
- import { agentCallKey, loadResumeCache, RunJournal } from "./journal.js";
5
+ import { readSkipRequests, writeStatus } from "./control.js";
6
+ import { agentCallKey, loadResumeCache, loadResumeCalls, RunJournal } from "./journal.js";
7
+ import { resolveMcpServers } from "./mcp.js";
8
+ import { checkCompile, createRealm } from "./sandbox.js";
6
9
  import { balancedSlice, extractJson, INVALID, structuredOutputInstruction, structuredOutputRetryPrompt, validateAgainstSchema, } from "./schema.js";
7
- const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
8
- const SCRIPT_PARAMS = ["agent", "parallel", "pipeline", "phase", "log", "args", "budget", "Date", "Math"];
10
+ import { createWorktree, finishWorktree } from "./worktree.js";
11
+ const SCRIPT_PARAMS = ["agent", "parallel", "pipeline", "phase", "log", "args", "budget", "workflow"];
12
+ function errorMessage(error) {
13
+ return typeof error === "object" &&
14
+ error !== null &&
15
+ "message" in error &&
16
+ typeof error.message === "string"
17
+ ? error.message
18
+ : String(error);
19
+ }
20
+ function isPlainObject(value) {
21
+ if (Object.prototype.toString.call(value) !== "[object Object]")
22
+ return false;
23
+ const proto = Object.getPrototypeOf(value);
24
+ return proto === null || Object.getPrototypeOf(proto) === null;
25
+ }
26
+ function validateConfig(value) {
27
+ if (value === undefined)
28
+ return undefined;
29
+ if (!isPlainObject(value) || Object.values(value).some((entry) => typeof entry !== "string")) {
30
+ throw new Error("agent() config takes an object of configId → string value");
31
+ }
32
+ return value;
33
+ }
9
34
  export function loadWorkflow(file) {
10
35
  const absolute = resolve(file);
11
36
  const source = readFileSync(absolute, "utf8");
@@ -33,33 +58,8 @@ export function loadWorkflow(file) {
33
58
  }
34
59
  /** Compile without running — catches syntax errors and non-literal meta. */
35
60
  export function compileWorkflow(workflow) {
36
- new AsyncFunction(...SCRIPT_PARAMS, workflow.body);
61
+ checkCompile(SCRIPT_PARAMS, workflow.body);
37
62
  }
38
- // Wall-clock and randomness would silently break resume replay, so scripts get
39
- // guarded shims instead of the real globals. Pass timestamps in via --args.
40
- const guardedDate = new Proxy(Date, {
41
- get(target, prop, receiver) {
42
- if (prop === "now") {
43
- return () => {
44
- throw new Error("Date.now() is unavailable in workflows (breaks resume); pass timestamps via --args");
45
- };
46
- }
47
- return Reflect.get(target, prop, receiver);
48
- },
49
- construct(target, argArray) {
50
- if (argArray.length === 0) {
51
- throw new Error("new Date() without arguments is unavailable in workflows (breaks resume)");
52
- }
53
- return Reflect.construct(target, argArray);
54
- },
55
- });
56
- const guardedMath = Object.create(Math, {
57
- random: {
58
- value: () => {
59
- throw new Error("Math.random() is unavailable in workflows (breaks resume); vary prompts by index instead");
60
- },
61
- },
62
- });
63
63
  class Semaphore {
64
64
  queue = [];
65
65
  available;
@@ -89,11 +89,13 @@ const AGENT_PREAMBLE = [
89
89
  ].join("\n");
90
90
  export async function runWorkflow(options) {
91
91
  const startedAt = Date.now();
92
+ const statusStartedAt = new Date().toISOString();
92
93
  const workflow = loadWorkflow(options.file);
93
- const scriptFn = new AsyncFunction(...SCRIPT_PARAMS, workflow.body);
94
94
  const workspace = resolve(options.cwd);
95
- const journal = new RunJournal(workspace);
96
- const resumeCache = options.resumeRunId ? loadResumeCache(workspace, options.resumeRunId) : undefined;
95
+ const journal = new RunJournal(workspace, options.runId);
96
+ const resumeMode = options.resumeMode ?? "prefix";
97
+ const resumeCache = options.resumeRunId && resumeMode === "hash" ? loadResumeCache(workspace, options.resumeRunId) : undefined;
98
+ const priorCalls = options.resumeRunId && resumeMode !== "hash" ? loadResumeCalls(workspace, options.resumeRunId) : undefined;
97
99
  const reporter = options.reporter;
98
100
  const maxAgents = options.maxAgents ?? 200;
99
101
  const agentTimeoutMs = options.agentTimeoutMs ?? 20 * 60 * 1000;
@@ -103,6 +105,33 @@ export async function runWorkflow(options) {
103
105
  let agentCounter = 0;
104
106
  let outputTokens = 0;
105
107
  let currentPhase;
108
+ let prefixIntact = true;
109
+ const liveSessions = new Map();
110
+ const skipped = new Set();
111
+ const controlTimer = setInterval(() => {
112
+ for (const id of readSkipRequests(journal.runDir)) {
113
+ if (skipped.has(id))
114
+ continue;
115
+ skipped.add(id);
116
+ liveSessions.get(id)?.abort("skipped via newton skip");
117
+ }
118
+ }, 500);
119
+ controlTimer.unref();
120
+ function syncStatus(status, extra = {}) {
121
+ writeStatus(journal.runDir, {
122
+ runId: journal.runId,
123
+ pid: process.pid,
124
+ file: workflow.file,
125
+ name: workflow.meta.name,
126
+ detached: options.detached ?? false,
127
+ startedAt: statusStartedAt,
128
+ updatedAt: new Date().toISOString(),
129
+ status,
130
+ agents: agentCounter,
131
+ outputTokens,
132
+ ...extra,
133
+ });
134
+ }
106
135
  journal.append({
107
136
  type: "workflow_start",
108
137
  runId: journal.runId,
@@ -110,8 +139,10 @@ export async function runWorkflow(options) {
110
139
  name: workflow.meta.name,
111
140
  args: options.args ?? null,
112
141
  resumedFrom: options.resumeRunId ?? null,
142
+ resumeMode: options.resumeRunId ? resumeMode : null,
113
143
  });
114
144
  reporter.start(workflow.meta, journal.runId, journal.runDir);
145
+ syncStatus("running");
115
146
  const budget = {
116
147
  total: budgetTotal,
117
148
  spent: () => outputTokens,
@@ -135,31 +166,107 @@ export async function runWorkflow(options) {
135
166
  if (budgetTotal !== null && outputTokens >= budgetTotal) {
136
167
  throw new Error(`token budget exhausted (${outputTokens}/${budgetTotal} output tokens)`);
137
168
  }
138
- const key = agentCallKey(prompt, spec, callOptions.schema ?? null);
169
+ const config = validateConfig(callOptions.config);
170
+ const key = agentCallKey(prompt, { ...spec, isolation: callOptions.isolation, mcp: callOptions.mcp, config }, callOptions.schema ?? null);
139
171
  const label = callOptions.label ?? prompt.replace(/\s+/g, " ").trim().slice(0, 64);
140
172
  const phase = callOptions.phase ?? currentPhase;
173
+ if (priorCalls && prefixIntact) {
174
+ const prior = priorCalls.get(id);
175
+ if (!prior || prior.key !== key) {
176
+ prefixIntact = false;
177
+ }
178
+ else if (prior.ok) {
179
+ journal.append({ type: "agent_end", id, key, label, phase, status: "ok", cached: true, result: prior.result });
180
+ syncStatus("running");
181
+ reporter.agentStart(id, label, spec, phase, true);
182
+ reporter.agentEnd(id, true, 0, "cached");
183
+ return prior.result;
184
+ }
185
+ }
141
186
  const cachedQueue = resumeCache?.get(key);
142
187
  if (cachedQueue && cachedQueue.length > 0) {
143
188
  const result = cachedQueue.shift();
144
189
  journal.append({ type: "agent_end", id, key, label, phase, status: "ok", cached: true, result });
190
+ syncStatus("running");
145
191
  reporter.agentStart(id, label, spec, phase, true);
146
192
  reporter.agentEnd(id, true, 0, "cached");
147
193
  return result;
148
194
  }
195
+ const mcpServers = callOptions.mcp !== undefined ? resolveMcpServers(workspace, callOptions.mcp) : undefined;
149
196
  await semaphore.acquire();
150
197
  const agentStartedAt = Date.now();
151
- reporter.agentStart(id, label, spec, phase, false);
152
- journal.append({ type: "agent_start", id, key, label, phase, spec, prompt });
153
- const trace = journal.traceWriter(id);
198
+ let session;
199
+ let wt;
200
+ let worktreeFinished = false;
201
+ // Idempotent so the finally can guarantee cleanup on a throw without
202
+ // double-finalizing the worktree that the normal path already removed.
203
+ const finishCreatedWorktree = async () => {
204
+ if (!wt || worktreeFinished)
205
+ return undefined;
206
+ worktreeFinished = true;
207
+ const wtInfo = await finishWorktree(workspace, wt);
208
+ const worktree = { path: wt.path, dirty: wtInfo.dirty, removed: wtInfo.removed };
209
+ if (wtInfo.dirty)
210
+ reporter.log(`worktree kept: ${wt.path}`);
211
+ return worktree;
212
+ };
154
213
  try {
214
+ reporter.agentStart(id, label, spec, phase, false);
215
+ journal.append({ type: "agent_start", id, key, label, phase, spec, prompt });
216
+ const trace = journal.traceWriter(id);
217
+ if (skipped.has(id)) {
218
+ journal.append({ type: "agent_end", id, key, label, phase, status: "error", error: "skipped via newton skip", seconds: 0 });
219
+ syncStatus("running");
220
+ reporter.agentEnd(id, false, 0, "skipped");
221
+ return null;
222
+ }
223
+ if (callOptions.isolation === "worktree") {
224
+ try {
225
+ wt = await createWorktree(workspace, journal.runId, id);
226
+ }
227
+ catch (error) {
228
+ const message = errorMessage(error);
229
+ journal.append({ type: "agent_end", id, key, label, phase, status: "error", error: message, seconds: 0 });
230
+ syncStatus("running");
231
+ reporter.agentEnd(id, false, 0, message);
232
+ return null;
233
+ }
234
+ }
235
+ if (skipped.has(id)) {
236
+ const worktree = await finishCreatedWorktree();
237
+ journal.append({
238
+ type: "agent_end",
239
+ id,
240
+ key,
241
+ label,
242
+ phase,
243
+ status: "error",
244
+ error: "skipped via newton skip",
245
+ seconds: 0,
246
+ ...(worktree ? { worktree } : {}),
247
+ });
248
+ syncStatus("running");
249
+ reporter.agentEnd(id, false, 0, "skipped");
250
+ return null;
251
+ }
155
252
  const fullPrompt = AGENT_PREAMBLE + prompt + (callOptions.schema ? structuredOutputInstruction(callOptions.schema) : "");
156
- const session = new AcpAgentSession({
253
+ const cwd = wt
254
+ ? callOptions.cwd
255
+ ? resolve(wt.path, callOptions.cwd)
256
+ : wt.path
257
+ : callOptions.cwd
258
+ ? resolve(workspace, callOptions.cwd)
259
+ : workspace;
260
+ session = new AcpAgentSession({
157
261
  spec,
158
262
  prompt: fullPrompt,
159
- cwd: callOptions.cwd ? resolve(workspace, callOptions.cwd) : workspace,
263
+ cwd,
160
264
  timeoutMs: callOptions.timeoutMs ?? agentTimeoutMs,
265
+ mcpServers,
266
+ config,
161
267
  trace,
162
268
  });
269
+ liveSessions.set(id, session);
163
270
  let turn = await session.prompt();
164
271
  let result = turn.text;
165
272
  let failure = turn.error;
@@ -185,11 +292,23 @@ export async function runWorkflow(options) {
185
292
  }
186
293
  }
187
294
  session.close();
295
+ const worktree = await finishCreatedWorktree();
188
296
  const seconds = Math.round((Date.now() - agentStartedAt) / 1000);
189
297
  const turnTokens = turn.usage?.outputTokens ?? 0;
190
298
  outputTokens += turnTokens;
191
299
  if (failure) {
192
- journal.append({ type: "agent_end", id, key, label, phase, status: "error", error: failure, seconds });
300
+ journal.append({
301
+ type: "agent_end",
302
+ id,
303
+ key,
304
+ label,
305
+ phase,
306
+ status: "error",
307
+ error: failure,
308
+ seconds,
309
+ ...(worktree ? { worktree } : {}),
310
+ });
311
+ syncStatus("running");
193
312
  reporter.agentEnd(id, false, seconds, failure);
194
313
  return null;
195
314
  }
@@ -204,11 +323,19 @@ export async function runWorkflow(options) {
204
323
  toolCalls: turn.toolCalls,
205
324
  usage: turn.usage,
206
325
  result,
326
+ ...(worktree ? { worktree } : {}),
207
327
  });
328
+ syncStatus("running");
208
329
  reporter.agentEnd(id, true, seconds, turn.usage ? `${turn.toolCalls} tools · ${turnTokens} out-tok` : `${turn.toolCalls} tools`);
209
330
  return result;
210
331
  }
211
332
  finally {
333
+ if (session) {
334
+ liveSessions.delete(id);
335
+ session.close();
336
+ }
337
+ // No-op unless a throw skipped the normal finalize (guard above).
338
+ await finishCreatedWorktree().catch(() => { });
212
339
  semaphore.release();
213
340
  }
214
341
  }
@@ -219,7 +346,7 @@ export async function runWorkflow(options) {
219
346
  return Promise.all(thunks.map((thunk) => Promise.resolve()
220
347
  .then(thunk)
221
348
  .catch((error) => {
222
- const message = error instanceof Error ? error.message : String(error);
349
+ const message = errorMessage(error);
223
350
  journal.append({ type: "log", message: `parallel task failed: ${message}` });
224
351
  reporter.log(`parallel task failed: ${message}`);
225
352
  return null;
@@ -238,7 +365,7 @@ export async function runWorkflow(options) {
238
365
  previous = await stage(previous, item, index);
239
366
  }
240
367
  catch (error) {
241
- const message = error instanceof Error ? error.message : String(error);
368
+ const message = errorMessage(error);
242
369
  journal.append({ type: "log", message: `pipeline item ${index} dropped: ${message}` });
243
370
  reporter.log(`pipeline item ${index} dropped: ${message}`);
244
371
  return null;
@@ -253,14 +380,64 @@ export async function runWorkflow(options) {
253
380
  currentPhase = title;
254
381
  journal.append({ type: "phase", title });
255
382
  reporter.phase(title);
383
+ syncStatus("running");
256
384
  }
257
385
  function logFn(message) {
258
386
  const text = typeof message === "string" ? message : JSON.stringify(message);
259
387
  journal.append({ type: "log", message: text });
260
388
  reporter.log(text);
261
389
  }
390
+ const realm = createRealm((message) => logFn(message));
391
+ async function executeScript(loaded, scriptArgs, depth) {
392
+ const scriptFn = realm.compile(SCRIPT_PARAMS, loaded.body, loaded.file);
393
+ return scriptFn(agentFn, parallelFn, pipelineFn, phaseFn, logFn, scriptArgs, budget, makeWorkflowFn(loaded.file, depth));
394
+ }
395
+ function makeWorkflowFn(callerFile, depth) {
396
+ return async (nameOrPath, childArgs) => {
397
+ if (depth >= 1)
398
+ throw new Error("workflow() nesting is limited to one level");
399
+ if (typeof nameOrPath !== "string")
400
+ throw new Error("workflow() takes a workflow name or path string");
401
+ const registryDir = join(workspace, ".newton", "workflows");
402
+ const file = /\.(js|mjs)$/.test(nameOrPath) || nameOrPath.includes("/")
403
+ ? resolve(dirname(callerFile), nameOrPath)
404
+ : join(registryDir, `${nameOrPath}.js`);
405
+ if (!existsSync(file)) {
406
+ let names = [];
407
+ try {
408
+ names = readdirSync(registryDir, { withFileTypes: true })
409
+ .filter((entry) => entry.isFile() && /\.(js|mjs)$/.test(entry.name))
410
+ .map((entry) => entry.name.replace(/\.(?:js|mjs)$/, ""))
411
+ .sort();
412
+ }
413
+ catch {
414
+ names = [];
415
+ }
416
+ throw new Error(`workflow "${nameOrPath}" not found at ${file}; registered workflows: ${names.length > 0 ? names.join(", ") : "(none)"}`);
417
+ }
418
+ const child = loadWorkflow(file);
419
+ journal.append({ type: "workflow_child_start", name: child.meta.name, file: child.file, args: childArgs ?? null });
420
+ reporter.log(`▸ workflow ${child.meta.name}`);
421
+ const savedPhase = currentPhase;
422
+ try {
423
+ const result = await executeScript(child, childArgs, depth + 1);
424
+ journal.append({ type: "workflow_child_end", name: child.meta.name, status: "ok" });
425
+ reporter.log(`✔ workflow ${child.meta.name}`);
426
+ return result;
427
+ }
428
+ catch (error) {
429
+ const message = errorMessage(error);
430
+ journal.append({ type: "workflow_child_end", name: child.meta.name, status: "error", error: message });
431
+ reporter.log(`✖ workflow ${child.meta.name}: ${message}`);
432
+ throw error;
433
+ }
434
+ finally {
435
+ currentPhase = savedPhase;
436
+ }
437
+ };
438
+ }
262
439
  try {
263
- const result = await scriptFn(agentFn, parallelFn, pipelineFn, phaseFn, logFn, options.args, budget, guardedDate, guardedMath);
440
+ const result = await executeScript(workflow, options.args, 0);
264
441
  const runResult = {
265
442
  runId: journal.runId,
266
443
  status: "ok",
@@ -270,11 +447,13 @@ export async function runWorkflow(options) {
270
447
  durationMs: Date.now() - startedAt,
271
448
  };
272
449
  journal.append({ type: "workflow_end", status: "ok", result: runResult.result, agents: agentCounter, outputTokens });
450
+ syncStatus("ok", { result: runResult.result });
273
451
  return runResult;
274
452
  }
275
453
  catch (error) {
276
- const message = error instanceof Error ? error.message : String(error);
454
+ const message = errorMessage(error);
277
455
  journal.append({ type: "workflow_end", status: "error", error: message, agents: agentCounter, outputTokens });
456
+ syncStatus("error", { error: message });
278
457
  return {
279
458
  runId: journal.runId,
280
459
  status: "error",
@@ -284,4 +463,7 @@ export async function runWorkflow(options) {
284
463
  durationMs: Date.now() - startedAt,
285
464
  };
286
465
  }
466
+ finally {
467
+ clearInterval(controlTimer);
468
+ }
287
469
  }
package/dist/journal.js CHANGED
@@ -27,7 +27,7 @@ export class RunJournal {
27
27
  };
28
28
  }
29
29
  }
30
- function newRunId() {
30
+ export function newRunId() {
31
31
  const now = new Date();
32
32
  const stamp = now
33
33
  .toISOString()
@@ -43,8 +43,14 @@ export function agentCallKey(prompt, spec, schema) {
43
43
  .slice(0, 32);
44
44
  }
45
45
  /**
46
- * Resume cache: successful agent results from a prior run, keyed by
47
- * (prompt, spec, schema) hash. Duplicate keys replay in call order.
46
+ * Resume has two modes:
47
+ * - prefix: load prior calls by positional id via loadResumeCalls.
48
+ * - hash: load successful results by (prompt, spec, schema) hash here.
49
+ *
50
+ * Invariant: journal `id` is the positional sequence. Agent ids increment at
51
+ * every agent() call, including cached calls.
52
+ *
53
+ * Duplicate hash keys replay in call order.
48
54
  */
49
55
  export function loadResumeCache(workspace, runId) {
50
56
  const journalPath = join(workspace, ".newton", "runs", runId, "journal.jsonl");
@@ -70,3 +76,34 @@ export function loadResumeCache(workspace, runId) {
70
76
  }
71
77
  return cache;
72
78
  }
79
+ export function loadResumeCalls(workspace, runId) {
80
+ const journalPath = join(workspace, ".newton", "runs", runId, "journal.jsonl");
81
+ if (!existsSync(journalPath)) {
82
+ throw new Error(`No journal found for run "${runId}" (looked at ${journalPath})`);
83
+ }
84
+ const calls = new Map();
85
+ for (const line of readFileSync(journalPath, "utf8").split("\n")) {
86
+ if (!line.trim())
87
+ continue;
88
+ let entry;
89
+ try {
90
+ entry = JSON.parse(line);
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ const id = entry.id;
96
+ const key = entry.key;
97
+ if (typeof id !== "number" || !Number.isFinite(id) || typeof key !== "string")
98
+ continue;
99
+ if (entry.type === "agent_start") {
100
+ if (!calls.has(id)) {
101
+ calls.set(id, { key, ok: false });
102
+ }
103
+ }
104
+ else if (entry.type === "agent_end") {
105
+ calls.set(id, { key, ok: entry.status === "ok", result: entry.result });
106
+ }
107
+ }
108
+ return calls;
109
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,102 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function errorMessage(error) {
7
+ return isRecord(error) && typeof error.message === "string" ? error.message : String(error);
8
+ }
9
+ function readMcpConfig(workspace) {
10
+ const path = join(workspace, ".mcp.json");
11
+ let parsed;
12
+ try {
13
+ parsed = JSON.parse(readFileSync(path, "utf8"));
14
+ }
15
+ catch (error) {
16
+ if (isRecord(error) && error.code === "ENOENT") {
17
+ throw new Error(`MCP config file not found at ${path}`);
18
+ }
19
+ throw new Error(`Could not read ${path}: ${errorMessage(error)}`);
20
+ }
21
+ if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) {
22
+ throw new Error(`${path}: expected { "mcpServers": { ... } }`);
23
+ }
24
+ return { path, servers: parsed.mcpServers };
25
+ }
26
+ function availableNames(servers) {
27
+ const names = Object.keys(servers).sort();
28
+ return names.length > 0 ? names.join(", ") : "(none)";
29
+ }
30
+ function optionalStringArray(value, field) {
31
+ if (value === undefined || value === null)
32
+ return [];
33
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
34
+ throw new Error(`${field} must be an array of strings`);
35
+ }
36
+ return value;
37
+ }
38
+ function nameValueEntries(value, field) {
39
+ if (value === undefined || value === null)
40
+ return [];
41
+ if (!isRecord(value))
42
+ throw new Error(`${field} must be an object`);
43
+ return Object.entries(value).map(([name, entryValue]) => ({ name, value: String(entryValue) }));
44
+ }
45
+ function toMcpServer(name, value, source) {
46
+ if (!isRecord(value))
47
+ throw new Error(`${source} must be an object`);
48
+ if (value.type !== undefined) {
49
+ if (value.type !== "http" && value.type !== "sse") {
50
+ throw new Error(`${source} type must be "http" or "sse"`);
51
+ }
52
+ if (typeof value.url !== "string" || !value.url) {
53
+ throw new Error(`${source} missing url`);
54
+ }
55
+ return {
56
+ type: value.type,
57
+ name,
58
+ url: value.url,
59
+ headers: nameValueEntries(value.headers, `${source} headers`),
60
+ };
61
+ }
62
+ if (typeof value.command !== "string" || !value.command) {
63
+ throw new Error(`${source} missing command`);
64
+ }
65
+ return {
66
+ name,
67
+ command: value.command,
68
+ args: optionalStringArray(value.args, `${source} args`),
69
+ env: nameValueEntries(value.env, `${source} env`),
70
+ };
71
+ }
72
+ export function resolveMcpServers(workspace, opt) {
73
+ if (opt === true) {
74
+ const config = readMcpConfig(workspace);
75
+ return Object.entries(config.servers).map(([name, server]) => toMcpServer(name, server, `MCP server "${name}"`));
76
+ }
77
+ if (!Array.isArray(opt)) {
78
+ throw new Error("agent() mcp option takes true or an array of names/servers");
79
+ }
80
+ let config;
81
+ const getConfig = () => {
82
+ config ??= readMcpConfig(workspace);
83
+ return config;
84
+ };
85
+ return opt.map((entry) => {
86
+ if (typeof entry === "string") {
87
+ const loaded = getConfig();
88
+ const server = loaded.servers[entry];
89
+ if (server === undefined) {
90
+ throw new Error(`MCP server "${entry}" not found in ${loaded.path}; available: ${availableNames(loaded.servers)}`);
91
+ }
92
+ return toMcpServer(entry, server, `MCP server "${entry}"`);
93
+ }
94
+ if (!isRecord(entry)) {
95
+ throw new Error("agent() mcp option takes true or an array of names/servers");
96
+ }
97
+ if (typeof entry.name !== "string" || !entry.name.trim()) {
98
+ throw new Error("inline MCP server missing name");
99
+ }
100
+ return toMcpServer(entry.name, entry, `inline MCP server "${entry.name}"`);
101
+ });
102
+ }
@@ -0,0 +1,157 @@
1
+ import * as vm from "node:vm";
2
+ export function createRealm(consoleLog) {
3
+ const context = vm.createContext({}, { codeGeneration: { strings: false, wasm: false } });
4
+ const hostCallables = new Map();
5
+ let nextCallableId = 1;
6
+ let makeRealmCallable;
7
+ let makeRealmArray;
8
+ let makeRealmObject;
9
+ vm.runInContext(`
10
+ {
11
+ const RealmDate = Date;
12
+ const dateNowUnavailable = () => {
13
+ throw new Error("Date.now() is unavailable in workflows (breaks resume); pass timestamps via --args");
14
+ };
15
+ const mathRandomUnavailable = () => {
16
+ throw new Error("Math.random() is unavailable in workflows (breaks resume); vary prompts by index instead");
17
+ };
18
+
19
+ const GuardedDate = new Proxy(RealmDate, {
20
+ get(target, prop, receiver) {
21
+ if (prop === "now") return dateNowUnavailable;
22
+ return Reflect.get(target, prop, receiver);
23
+ },
24
+ construct(target, argArray) {
25
+ if (argArray.length === 0) {
26
+ throw new Error("new Date() without arguments is unavailable in workflows (breaks resume)");
27
+ }
28
+ return Reflect.construct(target, argArray);
29
+ },
30
+ });
31
+ globalThis.Date = GuardedDate;
32
+ Object.defineProperty(RealmDate.prototype, "constructor", {
33
+ value: GuardedDate,
34
+ writable: true,
35
+ configurable: true,
36
+ });
37
+
38
+ Math.random = mathRandomUnavailable;
39
+ }
40
+ `, context);
41
+ const bridge = (id, args) => {
42
+ const callable = hostCallables.get(id);
43
+ if (!callable)
44
+ throw new Error(`unknown workflow host function ${id}`);
45
+ return callable(...args);
46
+ };
47
+ context.__newtonBridge = bridge;
48
+ const helpers = vm.runInContext(`
49
+ (() => {
50
+ const bridge = globalThis.__newtonBridge;
51
+ delete globalThis.__newtonBridge;
52
+
53
+ const importValue = (value, seen = new WeakMap()) => {
54
+ if (value === null || value === undefined) return value;
55
+ const kind = typeof value;
56
+ if (kind !== "object") return kind === "function" ? undefined : value;
57
+ const existing = seen.get(value);
58
+ if (existing) return existing;
59
+ if (Array.isArray(value)) {
60
+ const copy = [];
61
+ seen.set(value, copy);
62
+ for (let i = 0; i < value.length; i++) copy[i] = importValue(value[i], seen);
63
+ return copy;
64
+ }
65
+ const copy = {};
66
+ seen.set(value, copy);
67
+ for (const key of Object.keys(value)) copy[key] = importValue(value[key], seen);
68
+ return copy;
69
+ };
70
+
71
+ const importError = (error) =>
72
+ new Error(error && typeof error === "object" && typeof error.message === "string" ? error.message : String(error));
73
+
74
+ const makeCallable = (id) => (...args) => {
75
+ try {
76
+ const result = bridge(id, args);
77
+ if (result && typeof result === "object" && typeof result.then === "function") {
78
+ return Promise.resolve(result).then(
79
+ (value) => importValue(value),
80
+ (error) => {
81
+ throw importError(error);
82
+ },
83
+ );
84
+ }
85
+ return importValue(result);
86
+ } catch (error) {
87
+ throw importError(error);
88
+ }
89
+ };
90
+
91
+ return {
92
+ makeCallable,
93
+ makeArray: (items) => Array.from(items),
94
+ makeObject: (entries) => {
95
+ const object = {};
96
+ for (const [key, value] of entries) object[key] = value;
97
+ return object;
98
+ },
99
+ };
100
+ })()
101
+ `, context);
102
+ makeRealmCallable = helpers.makeCallable;
103
+ makeRealmArray = helpers.makeArray;
104
+ makeRealmObject = helpers.makeObject;
105
+ const wrapHostCallable = (callable) => {
106
+ const id = nextCallableId++;
107
+ hostCallables.set(id, callable);
108
+ return makeRealmCallable(id);
109
+ };
110
+ const toRealmValue = (value) => {
111
+ if (value === null || value === undefined)
112
+ return value;
113
+ if (typeof value === "function")
114
+ return wrapHostCallable(value);
115
+ if (typeof value !== "object")
116
+ return value;
117
+ if (Array.isArray(value))
118
+ return makeRealmArray(value.map((item) => toRealmValue(item)));
119
+ return makeRealmObject(Object.entries(value).map(([key, entry]) => [key, toRealmValue(entry)]));
120
+ };
121
+ const writeConsole = (...args) => {
122
+ consoleLog(args.map(formatConsoleArg).join(" "));
123
+ };
124
+ context.__newtonConsole = makeRealmObject([
125
+ ["log", wrapHostCallable(writeConsole)],
126
+ ["info", wrapHostCallable(writeConsole)],
127
+ ["warn", wrapHostCallable(writeConsole)],
128
+ ["error", wrapHostCallable(writeConsole)],
129
+ ["debug", wrapHostCallable(writeConsole)],
130
+ ]);
131
+ vm.runInContext(`
132
+ globalThis.console = globalThis.__newtonConsole;
133
+ delete globalThis.__newtonConsole;
134
+ `, context);
135
+ return {
136
+ compile(params, body, filename) {
137
+ const scriptFn = vm.runInContext(`(async function(${params.join(",")}) {"use strict";\n${body}\n})`, context, {
138
+ filename,
139
+ });
140
+ return (...args) => scriptFn(...args.map((arg) => toRealmValue(arg)));
141
+ },
142
+ };
143
+ }
144
+ export function checkCompile(params, body) {
145
+ new vm.Script('(async function(' + params.join(",") + ') {"use strict";\n' + body + "\n})");
146
+ }
147
+ function formatConsoleArg(arg) {
148
+ if (typeof arg === "string")
149
+ return arg;
150
+ try {
151
+ const json = JSON.stringify(arg);
152
+ return json === undefined ? String(arg) : json;
153
+ }
154
+ catch {
155
+ return String(arg);
156
+ }
157
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.3.0";
1
+ export const VERSION = "0.4.0";
@@ -0,0 +1,51 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdirSync, rmdirSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ const execFileAsync = promisify(execFile);
6
+ export async function createWorktree(workspace, runId, agentId) {
7
+ const path = join(workspace, ".newton", "worktrees", runId, `agent-${agentId}`);
8
+ mkdirSync(dirname(path), { recursive: true });
9
+ try {
10
+ // Detached HEAD avoids branch-name coordination across parallel agents.
11
+ await git(["-C", workspace, "worktree", "add", "--detach", path, "HEAD"]);
12
+ const baseHead = (await git(["-C", path, "rev-parse", "HEAD"])).trim();
13
+ return { path, baseHead };
14
+ }
15
+ catch (error) {
16
+ throw new Error(`worktree setup failed: ${gitStderr(error)}`);
17
+ }
18
+ }
19
+ export async function finishWorktree(workspace, wt) {
20
+ try {
21
+ const status = await git(["-C", wt.path, "status", "--porcelain"]);
22
+ const head = (await git(["-C", wt.path, "rev-parse", "HEAD"])).trim();
23
+ const dirty = status.trim().length > 0 || head !== wt.baseHead;
24
+ if (dirty)
25
+ return { dirty: true, removed: false };
26
+ // Force is intentional after the clean check: this is disposable isolation.
27
+ await git(["-C", workspace, "worktree", "remove", "--force", wt.path]);
28
+ try {
29
+ rmdirSync(dirname(wt.path));
30
+ }
31
+ catch {
32
+ // Best-effort cleanup of the per-run directory.
33
+ }
34
+ return { dirty: false, removed: true };
35
+ }
36
+ catch {
37
+ return { dirty: true, removed: false };
38
+ }
39
+ }
40
+ async function git(args) {
41
+ const { stdout } = await execFileAsync("git", args);
42
+ return stdout.toString();
43
+ }
44
+ function gitStderr(error) {
45
+ if (typeof error === "object" && error !== null && "stderr" in error) {
46
+ const stderr = String(error.stderr ?? "").trim();
47
+ if (stderr)
48
+ return stderr;
49
+ }
50
+ return error instanceof Error ? error.message : String(error);
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gigaai/newton",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Deterministic multi-agent workflow executor. Write workflows in plain JavaScript; Newton drives coding agents over the Agent Client Protocol.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {