@resolveworks/fork 0.1.1 → 0.2.1

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.
Files changed (3) hide show
  1. package/README.md +23 -8
  2. package/index.ts +136 -101
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -33,11 +33,13 @@ Then restart pi, or `/reload`.
33
33
 
34
34
  The parent session gains three tools:
35
35
 
36
- - `spawn_agent({ task, branch? })` — writes the task to a file and opens a new tmux window running `pi @<taskfile>`. Returns immediately. The child sees the task plus normal project context, not the parent's conversation, so the task must be self-contained.
36
+ - `spawn_agent({ task, branch?, model? })` — writes the task to a file and opens a new tmux window running `pi @<taskfile>`. Returns immediately. The child sees the task plus normal project context, not the parent's conversation, so the task must be self-contained.
37
37
  - `message_agent({ id, text })` — steers a revision request into the child's current run, or starts a new turn when the child is idle.
38
38
  - `close_agent({ id })` — mechanical shutdown; no model turn spent.
39
39
 
40
- The child gains `report_result({ result })`. The report travels over a Unix socket and arrives in the parent as a steering notification: immediately if the parent is idle, or injected into the parent's current run after its pending tool calls. The child stays alive after reporting, awaiting the verdict: revisions via `message_agent`, or closure. Reports are outcomes, not progress updates. If delivery fails, the child is told unambiguously and retries with the same payload — a retry cannot double-deliver. Interrupting the parent would drop its queued notifications, so fork re-delivers them once the interrupted run settles — an escape can no longer lose a report.
40
+ The child gains `message_parent({ message })`. The message travels over a Unix socket and arrives in the parent as a steering notification: immediately if the parent is idle, or injected into the parent's current run after its pending tool calls. The child stays alive after replying, awaiting the verdict: revisions via `message_agent`, or closure. Messages are outcomes, not progress updates. If delivery fails, the child is told unambiguously and retries with the same payload — a retry cannot double-deliver. Interrupting the parent would drop its queued notifications, so fork re-delivers them once the interrupted run settles — an escape can no longer lose a message.
41
+
42
+ Input is tagged by sender: the task and every `message_agent` message reach the child wrapped in `<message-from-parent>`, while anything typed into the child's tmux window arrives unwrapped. The child's prompt uses that distinction to know who it is answering.
41
43
 
42
44
  Don't poll. Navigate windows with your usual tmux keys.
43
45
 
@@ -51,12 +53,29 @@ Fork deliberately does no environment setup and runs no checks. `git worktree ad
51
53
 
52
54
  ## Config
53
55
 
54
- `~/.pi/agent/fork.json`, overridden per-project by `.pi/fork.json` (read only in trusted projects). All fields optional; passed through to the child as CLI flags:
56
+ `~/.pi/agent/fork.json`, overridden per-project by `.pi/fork.json` (read only in trusted projects). A `default` profile plus named `options`, passed through to the child as CLI flags:
55
57
 
56
58
  ```json
57
- { "model": "glm-5.2", "provider": "zai", "thinking": "high" }
59
+ {
60
+ "default": { "model": "glm-5.2", "thinking": "high" },
61
+ "options": [
62
+ {
63
+ "tag": "small",
64
+ "description": "Fast and inexpensive; use for routine tasks.",
65
+ "model": "glm-4.7-air"
66
+ },
67
+ {
68
+ "tag": "big",
69
+ "description": "Stronger reasoning; use for complex design and difficult debugging.",
70
+ "model": "claude-opus-4-5",
71
+ "provider": "anthropic"
72
+ }
73
+ ]
74
+ }
58
75
  ```
59
76
 
77
+ Each option requires a `description`, shown to the parent model alongside its tag so it can select an appropriate profile. `spawn_agent` takes an optional `model` argument naming an option's tag; without it, the `default` profile applies, or pi's normal startup rules if there is none. The project file replaces global fields wholesale.
78
+
60
79
  ## Runtime state
61
80
 
62
81
  Under `~/.pi/agent/extensions/fork/` (sockets and task files are mode `0o600`):
@@ -76,7 +95,3 @@ pnpm format:check
76
95
  ```
77
96
 
78
97
  Use `pnpm format` to apply formatting.
79
-
80
- ## License
81
-
82
- MIT
package/index.ts CHANGED
@@ -3,22 +3,25 @@
3
3
  *
4
4
  * The parent registers `spawn_agent`, `message_agent`, and `close_agent`
5
5
  * tools. Calling spawn_agent opens a new tmux window running pi with the
6
- * given task and returns immediately. The child reports results through `report_result`;
7
- * each report is delivered over a Unix socket as a notification that reaches
8
- * the parent promptly: if the parent is idle the report starts a turn at
9
- * once; if it is mid-run the report is steered into that run — delivered
10
- * after its current tool calls, before the next LLM call. An interrupt
11
- * clears the steering queue without delivering queued custom messages, so
12
- * reports are tracked until delivery is confirmed and re-delivered when an
13
- * interrupted run settles. The child stays alive after
14
- * reporting: the parent reviews the report, sends revision requests with
6
+ * given task and returns immediately. The child replies to the parent through
7
+ * `message_parent`; each message is delivered over a Unix socket as a
8
+ * notification that reaches the parent promptly: if the parent is idle the
9
+ * message starts a turn at once; if it is mid-run the message is steered into
10
+ * that run — delivered after its current tool calls, before the next LLM call.
11
+ * An interrupt clears the steering queue without delivering queued custom
12
+ * messages, so messages are tracked until delivery is confirmed and
13
+ * re-delivered when an interrupted run settles. The child stays alive after
14
+ * replying: the parent reviews the message, sends revision requests with
15
15
  * message_agent, and closes the child with close_agent.
16
16
  *
17
- * Configure the subagent model via ~/.pi/agent/fork.json (global) or
18
- * .pi/fork.json (project, overrides global). Any field is optional:
19
- * { "model": "glm-5.2", "provider": "zai", "thinking": "high" }
20
- * Fork passes these settings to the child pi process as CLI flags, so pi
21
- * resolves them using its normal startup rules.
17
+ * Configure subagent models via ~/.pi/agent/fork.json (global) or
18
+ * .pi/fork.json (project, overrides global): a default profile plus named
19
+ * options selected by tag:
20
+ * { "default": { "model": "glm-5.2" },
21
+ * "options": [{ "tag": "small", "description": "Fast routine tasks",
22
+ * "model": "glm-4.7-air" }] }
23
+ * Fork passes the selected profile to the child pi process as CLI flags, so
24
+ * pi resolves them using its normal startup rules.
22
25
  *
23
26
  * Requires: run pi inside tmux.
24
27
  */
@@ -32,6 +35,7 @@ import * as path from "node:path";
32
35
  import {
33
36
  CONFIG_DIR_NAME,
34
37
  type ExtensionAPI,
38
+ type ExtensionContext,
35
39
  getAgentDir,
36
40
  getMarkdownTheme,
37
41
  keyHint,
@@ -155,7 +159,8 @@ type AgentMessage = { type: "message"; text: string };
155
159
 
156
160
  // ── config (~/.pi/agent/fork.json, .pi/fork.json) ───────────────────
157
161
 
158
- interface ForkConfig {
162
+ /** Model settings passed to the child as CLI flags. */
163
+ interface ModelProfile {
159
164
  /** Model id or pattern (supports `provider/id` and `:thinking`). */
160
165
  model?: string;
161
166
  provider?: string;
@@ -163,14 +168,22 @@ interface ForkConfig {
163
168
  thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
164
169
  }
165
170
 
171
+ /** A named profile, selectable via spawn_agent's model argument. */
172
+ interface ModelOption extends ModelProfile {
173
+ tag: string;
174
+ /** Shown to the parent model to help it select a profile. */
175
+ description: string;
176
+ }
177
+
178
+ interface ForkConfig {
179
+ /** Profile for spawns without a model argument. */
180
+ default?: ModelProfile;
181
+ options?: ModelOption[];
182
+ }
183
+
166
184
  function readConfig(file: string): ForkConfig {
167
- try {
168
- if (!fs.existsSync(file)) return {};
169
- return JSON.parse(fs.readFileSync(file, "utf-8")) as ForkConfig;
170
- } catch (err) {
171
- console.error(`fork: failed to load ${file}: ${err}`);
172
- return {};
173
- }
185
+ if (!fs.existsSync(file)) return {};
186
+ return JSON.parse(fs.readFileSync(file, "utf-8")) as ForkConfig;
174
187
  }
175
188
 
176
189
  /** Merge global + project config. Project wins; only read if trusted. */
@@ -180,6 +193,14 @@ function loadConfig(cwd: string, projectTrusted: boolean): ForkConfig {
180
193
  return { ...globalCfg, ...projectCfg };
181
194
  }
182
195
 
196
+ /** The profile for a named tag, or `default` when no tag is given. */
197
+ function resolveProfile(config: ForkConfig, tag: string | undefined): ModelProfile {
198
+ if (tag === undefined) return config.default ?? {};
199
+ const option = config.options?.find((option) => option.tag === tag);
200
+ if (option === undefined) throw new Error(`fork: unknown model tag "${tag}"`);
201
+ return option;
202
+ }
203
+
183
204
  // ── git worktrees ───────────────────────────────────────────────────
184
205
 
185
206
  interface WorktreeInfo {
@@ -266,15 +287,15 @@ function spawn(
266
287
  id: string,
267
288
  taskPath: string,
268
289
  cwd: string,
269
- config: ForkConfig,
290
+ profile: ModelProfile,
270
291
  branch?: string,
271
292
  ): void {
272
293
  // argv passed directly; tmux runs a multi-arg command without `sh -c`, so
273
294
  // no shell quoting is needed. Let pi resolve model settings itself.
274
295
  const cmdArgs = ["pi", "--subagent-socket", socketPathFor(sessionId), "--subagent-id", id];
275
- if (config.provider) cmdArgs.push("--provider", config.provider);
276
- if (config.model) cmdArgs.push("--model", config.model);
277
- if (config.thinking) cmdArgs.push("--thinking", config.thinking);
296
+ if (profile.provider) cmdArgs.push("--provider", profile.provider);
297
+ if (profile.model) cmdArgs.push("--model", profile.model);
298
+ if (profile.thinking) cmdArgs.push("--thinking", profile.thinking);
278
299
  if (branch) cmdArgs.push("--subagent-branch", branch);
279
300
  cmdArgs.push(`@${taskPath}`);
280
301
 
@@ -452,10 +473,10 @@ function assertOpenAgent(id: string): void {
452
473
  }
453
474
  }
454
475
 
455
- function setupParent(
456
- pi: ExtensionAPI,
457
- ctx: { sessionManager: { getSessionId: () => string } },
458
- ): void {
476
+ function setupParent(pi: ExtensionAPI, ctx: ExtensionContext): void {
477
+ const config = loadConfig(ctx.cwd, ctx.isProjectTrusted());
478
+ const modelOptions = config.options ?? [];
479
+
459
480
  fs.mkdirSync(SOCKETS_DIR, { recursive: true });
460
481
  fs.mkdirSync(TASKS_DIR, { recursive: true });
461
482
 
@@ -501,18 +522,17 @@ function setupParent(
501
522
  name: "spawn_agent",
502
523
  label: "Spawn Agent",
503
524
  description:
504
- "Delegate a self-contained task to a subagent: a fresh pi session in a " +
505
- "new tmux window. By default it shares your working tree; provide branch " +
506
- "to create an isolated Git worktree on a new agent/<name> branch. Use " +
507
- "for independent work that benefits from isolated context; do simple, " +
508
- "sequential, or context-dependent work yourself. The subagent sees the " +
509
- "task plus normal project context (AGENTS.md, files), but not this " +
510
- "conversation, so include needed context in the task. Avoid concurrent " +
511
- "editing agents in shared mode. Returns immediately with a spawn " +
512
- "confirmation. The report arrives later as a message in this " +
513
- "session — steered into current work or a new turn when idle; do not " +
514
- "poll or sleep. Continue other work or end your turn. Then " +
515
- "request revisions with message_agent or close it with close_agent.",
525
+ "Delegate a self-contained task to a subagent in a fresh pi session and " +
526
+ "tmux window. Use this for independent work that benefits from isolated " +
527
+ "context; handle simple, sequential, or context-dependent work yourself. " +
528
+ "By default, the subagent shares your working tree; do not run concurrent " +
529
+ "editing agents in shared mode. Set branch to use an isolated Git worktree. " +
530
+ "The subagent sees the task and normal project context, but not this " +
531
+ "conversation, so include all necessary context in the task. This tool " +
532
+ "returns immediately. The subagent's report steers your current run, or " +
533
+ "starts a new turn if you are idle. Do not poll or sleep; continue other " +
534
+ "work or end your turn. After the report arrives, review its work, then " +
535
+ "use message_agent for revisions or close_agent when finished.",
516
536
  parameters: Type.Object({
517
537
  task: Type.String({
518
538
  description:
@@ -532,16 +552,42 @@ function setupParent(
532
552
  "already exist. Omit to share the current working tree.",
533
553
  }),
534
554
  ),
555
+ ...(modelOptions.length > 0
556
+ ? {
557
+ model: Type.Optional(
558
+ Type.Enum(
559
+ modelOptions.map((option) => option.tag),
560
+ {
561
+ description:
562
+ "Model profile for the subagent:\n" +
563
+ modelOptions
564
+ .map((option) => `- ${option.tag}: ${option.description}`)
565
+ .join("\n") +
566
+ "\nOmit to use the default profile, or pi's normal startup rules " +
567
+ "if no default is configured.",
568
+ },
569
+ ),
570
+ ),
571
+ }
572
+ : {}),
535
573
  }),
536
574
  execute: async (_id, params, signal, _onUpdate, toolCtx) => {
537
- const { task, branch } = params as { task: string; branch?: string };
575
+ const { task, branch, model } = params as {
576
+ task: string;
577
+ branch?: string;
578
+ model?: string;
579
+ };
538
580
  const id = randomUUID();
539
581
  const taskPath = taskPathFor(id);
540
- fs.writeFileSync(taskPath, task, { mode: 0o600 });
582
+ // Tag the task like every other parent message, so the child learns one
583
+ // rule: tagged input is the parent, untagged input is a human at its window.
584
+ fs.writeFileSync(taskPath, `<message-from-parent>\n${task}\n</message-from-parent>`, {
585
+ mode: 0o600,
586
+ });
541
587
 
542
588
  let worktree: WorktreeInfo | undefined;
543
589
  try {
544
- const config = loadConfig(toolCtx.cwd, toolCtx.isProjectTrusted());
590
+ const profile = resolveProfile(config, model);
545
591
  if (branch !== undefined) {
546
592
  worktree = await createWorktree(pi, toolCtx.cwd, id, branch, signal);
547
593
  }
@@ -551,7 +597,7 @@ function setupParent(
551
597
  id,
552
598
  taskPath,
553
599
  worktree?.path ?? toolCtx.cwd,
554
- config,
600
+ profile,
555
601
  worktree?.branch,
556
602
  );
557
603
  } catch (err) {
@@ -583,10 +629,10 @@ function setupParent(
583
629
  label: "Message Agent",
584
630
  description:
585
631
  "Send a revision request or follow-up instruction to a live subagent. " +
586
- "It receives your text as a new turn, acts on it, and reports again. " +
587
- "Use after reviewing its report when changes are needed; use " +
588
- "close_agent when the work is accepted. The next report arrives as a " +
589
- "message in this session; do not poll or sleep.",
632
+ "The message steers its current run, or starts a new turn if it is idle. " +
633
+ "Use after reviewing its report when changes are needed. Its next report " +
634
+ "steers your current run, or starts a new turn if you are idle. Do not poll " +
635
+ "or sleep; continue other work or end your turn.",
590
636
  parameters: Type.Object({
591
637
  id: Type.String({
592
638
  description: "The subagent's UUID, from its spawn result or report.",
@@ -628,11 +674,10 @@ function setupParent(
628
674
  name: "close_agent",
629
675
  label: "Close Agent",
630
676
  description:
631
- "Shut down a live subagent's session without spending a model turn. " +
632
- "Use to accept its report or to stop one you no longer need. For an " +
633
- "isolated agent, this removes its worktree but retains its agent/<name> " +
634
- "branch for normal review, merge, or deletion. Subagents stay alive " +
635
- "until closed; close every subagent when you are done with it.",
677
+ "Shut down a live subagent without spending a model turn. Use after " +
678
+ "accepting its report or when the subagent is no longer needed. For an " +
679
+ "isolated subagent, this removes its worktree and discards uncommitted " +
680
+ "changes, but retains its agent/<name> branch. Close every subagent when finished.",
636
681
  parameters: Type.Object({
637
682
  id: Type.String({
638
683
  description: "The subagent's UUID, from its spawn result or report.",
@@ -697,19 +742,14 @@ function subagentSystemPrompt(branch?: string): string {
697
742
  "conversation. Focus only on the delegated task. " +
698
743
  workspace +
699
744
  "\n\n" +
700
- "# Reporting to the parent\n\n" +
701
- "report_result is the only channel that reaches the parent. It cannot " +
702
- "see your text replies, your tool output, or anything else in this " +
703
- "session — so a turn that ends in plain text leaves the parent waiting " +
704
- "forever, with no way to know you responded.\n\n" +
705
- "- Call report_result when the task is done, when you have acted on a " +
706
- "revision request from the parent, and when you are blocked and need " +
707
- "the parent's input.\n" +
708
- "- Every message from the parent calls for a report_result response, " +
709
- "even if the report is only a brief status update.\n" +
710
- "- After reporting, end your turn and wait. The session stays alive: " +
711
- "the parent reviews each report and either sends you a revision request " +
712
- "as an ordinary message or closes the session."
745
+ "# Communication\n\n" +
746
+ "- Parent messages, including your task, arrive in <message-from-parent> " +
747
+ "tags. Send answers, outcomes, and blockers back with message_parent; " +
748
+ "the parent cannot see your text replies.\n" +
749
+ "- Unwrapped messages come from the human using this session. Reply in " +
750
+ "text unless they ask you to relay something to the parent.\n\n" +
751
+ "After calling message_parent, end your turn and wait. The session stays " +
752
+ "alive for the parent's reply."
713
753
  );
714
754
  }
715
755
 
@@ -737,17 +777,14 @@ function processAgentLine(pi: ExtensionAPI, line: string): void {
737
777
  // steer: a revision request is news the child can fold into its current
738
778
  // run; it is delivered after the child's pending tool calls, before its
739
779
  // next LLM call.
740
- // The tags and the trailing instruction matter: without an explicit cue
741
- // to report back, a child may answer in plain text and end its turn,
742
- // which never reaches the parent.
780
+ // Match the spawn task's wrapper. The reminder makes the child reply over
781
+ // the socket rather than ending with a text reply the parent cannot see.
743
782
  pi.sendMessage(
744
783
  {
745
784
  customType: MESSAGE_TYPE,
746
785
  content:
747
786
  `<message-from-parent>\n${candidate.text}\n</message-from-parent>\n\n` +
748
- "Act on this message, then report the outcome with report_result. " +
749
- "The parent receives only report_result output — it cannot see " +
750
- "your text replies.",
787
+ "Act on this message, then reply with message_parent.",
751
788
  display: true,
752
789
  },
753
790
  { triggerTurn: true, deliverAs: "steer" },
@@ -770,37 +807,35 @@ function setupChild(
770
807
  };
771
808
  });
772
809
 
773
- // ── result reporting (child → parent) ──────────────────────────────
810
+ // ── message channel (child → parent) ───────────────────────────────
774
811
  //
775
812
  // Delivery failures are reported to the model, which retries with the same
776
- // result. Failures are unambiguous in practice: they mean the parent is
777
- // gone or the write failed, so a retry cannot deliver a report twice.
778
- let reportState: "open" | "sending" = "open";
813
+ // message. Failures are unambiguous in practice: they mean the parent is
814
+ // gone or the write failed, so a retry cannot deliver a message twice.
815
+ let sendState: "open" | "sending" = "open";
779
816
 
780
817
  pi.registerTool({
781
- name: "report_result",
782
- label: "Report Result",
818
+ name: "message_parent",
819
+ label: "Message Parent",
783
820
  description:
784
- "Send a result report to the parent — the only channel that reaches " +
785
- "it. The parent cannot see your text replies. Use when the task is " +
786
- "done, when reporting a requested revision, or when you are " +
787
- "blocked and need the parent's input. Report outcomes, not " +
788
- "progress updates.",
821
+ "Send a message to the parent session; it cannot see your text replies. " +
822
+ "Use it to answer the parent, report completed work, or ask for input " +
823
+ "when blocked. Send outcomes, not progress updates.",
789
824
  parameters: Type.Object({
790
- result: Type.String({
825
+ message: Type.String({
791
826
  description:
792
- "The complete result for the parent. Include the outcome and, when " +
793
- "relevant, edits, verification, and blockers.",
827
+ "The answer, outcome, or blocker for the parent. Include edits and " +
828
+ "verification when relevant.",
794
829
  }),
795
830
  }),
796
831
  execute: async (_id, params, _signal, _onUpdate, toolCtx) => {
797
- if (reportState !== "open") {
798
- throw new Error("report_result is already delivering a report");
832
+ if (sendState !== "open") {
833
+ throw new Error("message_parent is already sending a message");
799
834
  }
800
- reportState = "sending";
835
+ sendState = "sending";
801
836
 
802
- const { result } = params as { result: string };
803
- const payload = `${JSON.stringify({ id: subagentId, summary: result })}\n`;
837
+ const { message } = params as { message: string };
838
+ const payload = `${JSON.stringify({ id: subagentId, summary: message })}\n`;
804
839
 
805
840
  try {
806
841
  await new Promise<void>((resolve, reject) => {
@@ -823,25 +858,25 @@ function setupChild(
823
858
  socket.end(payload, () => finish());
824
859
  });
825
860
  } catch (err) {
826
- reportState = "open";
827
- const message = `fork: could not deliver result to parent: ${err}`;
828
- console.error(message);
861
+ sendState = "open";
862
+ const failure = `fork: could not deliver message to parent: ${err}`;
863
+ console.error(failure);
829
864
  toolCtx.ui.notify(
830
- `${message}. The report was not sent; retry report_result with the same result.`,
865
+ `${failure}. The message was not sent; retry message_parent with the same message.`,
831
866
  "error",
832
867
  );
833
868
  throw new Error(
834
- "Result delivery failed; the report was not sent. Retry " +
835
- "report_result with the same result.",
869
+ "Message delivery failed; the message was not sent. Retry " +
870
+ "message_parent with the same message.",
836
871
  );
837
872
  }
838
873
 
839
- reportState = "open";
874
+ sendState = "open";
840
875
  return {
841
876
  content: [
842
877
  {
843
878
  type: "text" as const,
844
- text: "Report delivered. End your turn and await the parent's verdict.",
879
+ text: "Delivered to the parent. End your turn and wait for its reply.",
845
880
  },
846
881
  ],
847
882
  details: {},
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@resolveworks/fork",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Subagents for Pi — spawn pi sessions in tmux windows with task delegation and revision loops",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "engines": {
7
8
  "node": ">=22.18.0"