@saadnvd1/agent-os 0.3.630 → 0.3.637

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 (45) hide show
  1. package/app/api/tasks/[id]/approve-plan/route.ts +6 -1
  2. package/app/api/tasks/[id]/export/route.ts +3 -2
  3. package/app/api/tasks/[id]/plan/route.ts +37 -0
  4. package/app/api/tasks/route.ts +4 -1
  5. package/app/globals.css +28 -0
  6. package/components/Chat/ChatPanel.tsx +2 -11
  7. package/components/Chat/EventTurns.tsx +1 -7
  8. package/components/Chat/Markdown.tsx +1 -0
  9. package/components/Chat/Messages.tsx +1 -1
  10. package/lib/approvals/enable.ts +9 -2
  11. package/lib/chat/events.ts +4 -0
  12. package/lib/chat/group.ts +3 -1
  13. package/lib/chat/reading-order.ts +50 -0
  14. package/lib/chat/turns.ts +22 -3
  15. package/lib/chat/worker/host.ts +3 -0
  16. package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
  17. package/lib/db/types.ts +6 -1
  18. package/lib/env-setup.ts +17 -4
  19. package/lib/models/plan-step.ts +75 -24
  20. package/lib/tasks/held.ts +23 -0
  21. package/lib/tasks/hold-checks.ts +134 -0
  22. package/lib/tasks/holds.ts +281 -0
  23. package/lib/tasks/import.ts +11 -3
  24. package/lib/tasks/index.ts +25 -3
  25. package/lib/tasks/move-bundle.ts +17 -2
  26. package/lib/tasks/move-guard.ts +9 -1
  27. package/lib/tasks/move.ts +5 -4
  28. package/lib/tasks/pause.ts +2 -31
  29. package/lib/tasks/placement.ts +16 -16
  30. package/lib/tasks/plan-view.ts +11 -3
  31. package/lib/tasks/queue-candidates.ts +151 -0
  32. package/lib/tasks/queue-placement.ts +155 -0
  33. package/lib/tasks/queue-rows.ts +236 -0
  34. package/lib/tasks/queue-start.ts +211 -0
  35. package/lib/tasks/queue-tick.ts +148 -0
  36. package/lib/tasks/queue-types.ts +141 -0
  37. package/lib/tasks/queue-view.ts +112 -0
  38. package/lib/tasks/queue.ts +53 -1096
  39. package/lib/tasks/remote-plan-path.ts +7 -0
  40. package/lib/tasks/remote-plan.ts +308 -0
  41. package/lib/tasks/remote.ts +18 -1
  42. package/lib/tasks/schedule.ts +44 -147
  43. package/package.json +1 -1
  44. package/scripts/check +55 -7
  45. package/lib/tasks/auto-hold.ts +0 -17
@@ -1,5 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { approvePlanNow } from "@/lib/models/plan-step";
3
+ import { callingDevice } from "@/lib/security/device-me";
4
+ import { planWaitRefusal } from "@/lib/tasks/held";
3
5
 
4
6
  // Approves a plan-only task's plan: it continues on the implementation
5
7
  // model. An optional message amends the plan (recorded as a scope change).
@@ -7,13 +9,16 @@ export async function POST(
7
9
  request: NextRequest,
8
10
  { params }: { params: Promise<{ id: string }> }
9
11
  ) {
12
+ const id = (await params).id;
13
+ const refusal = planWaitRefusal(id, callingDevice(request));
14
+ if (refusal) return NextResponse.json({ error: refusal }, { status: 403 });
10
15
  try {
11
16
  const body = await request.json().catch(() => ({}));
12
17
  const message =
13
18
  typeof body?.message === "string" && body.message.trim()
14
19
  ? body.message.trim().slice(0, 8000)
15
20
  : undefined;
16
- const out = await approvePlanNow((await params).id, message);
21
+ const out = await approvePlanNow(id, message);
17
22
  return NextResponse.json({ success: true, model: out.ref });
18
23
  } catch (error) {
19
24
  const message = error instanceof Error ? error.message : String(error);
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { exportOrResume } from "@/lib/tasks/move";
3
3
  import { callingDevice } from "@/lib/security/device-me";
4
- import { heldRefusal } from "@/lib/tasks/held";
4
+ import { heldRefusal, planWaitRefusal } from "@/lib/tasks/held";
5
5
 
6
6
  // POST /api/tasks/:id/export - stop the agent, push, hand over the task.
7
7
  // The caller confirms with /moved, or restarts it here with /resume; if it
@@ -11,7 +11,8 @@ export async function POST(
11
11
  { params }: { params: Promise<{ id: string }> }
12
12
  ) {
13
13
  const id = (await params).id;
14
- const held = heldRefusal(id, callingDevice(request));
14
+ const device = callingDevice(request);
15
+ const held = heldRefusal(id, device) ?? planWaitRefusal(id, device);
15
16
  if (held) return NextResponse.json({ error: held }, { status: 403 });
16
17
  try {
17
18
  const { to } = await request.json().catch(() => ({}));
@@ -0,0 +1,37 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { NextRequest, NextResponse } from "next/server";
4
+ import { db, type Session } from "@/lib/db";
5
+ import { callingDevice } from "@/lib/security/device-me";
6
+ import { heldRefusal, planWaitRefusal } from "@/lib/tasks/held";
7
+ import { MAX_PLAN_BYTES } from "@/lib/tasks/move-bundle";
8
+ import { PLAN_FILE } from "@/lib/models/plan-step";
9
+ import { expandHome } from "@/lib/tasks/session";
10
+
11
+ // GET /api/tasks/:id/plan - the plan file of a task whose plan is written, for
12
+ // the machine that placed the plan here and decides what follows it.
13
+ export async function GET(
14
+ request: NextRequest,
15
+ { params }: { params: Promise<{ id: string }> }
16
+ ) {
17
+ const id = (await params).id;
18
+ const device = callingDevice(request);
19
+ const refusal = heldRefusal(id, device) ?? planWaitRefusal(id, device);
20
+ if (refusal) return NextResponse.json({ error: refusal }, { status: 403 });
21
+ const s = db
22
+ .prepare(`SELECT plan_phase, worktree_path FROM sessions WHERE id = ?`)
23
+ .get(id) as Pick<Session, "plan_phase" | "worktree_path"> | undefined;
24
+ if (!s?.worktree_path)
25
+ return NextResponse.json({ error: "No such task" }, { status: 404 });
26
+ let text: string | null = null;
27
+ try {
28
+ const read = await fs.readFile(
29
+ path.join(expandHome(s.worktree_path), PLAN_FILE),
30
+ "utf8"
31
+ );
32
+ text = read.length > MAX_PLAN_BYTES ? read.slice(0, MAX_PLAN_BYTES) : read;
33
+ } catch {
34
+ // No plan file was written.
35
+ }
36
+ return NextResponse.json({ text, phase: s.plan_phase ?? null });
37
+ }
@@ -46,6 +46,7 @@ export async function POST(request: NextRequest) {
46
46
  plan,
47
47
  plan_reason,
48
48
  plan_only,
49
+ plan_waits,
49
50
  priority,
50
51
  machine,
51
52
  orchestrated,
@@ -76,7 +77,7 @@ export async function POST(request: NextRequest) {
76
77
  ? model
77
78
  : (parseOverride(model) ?? undefined),
78
79
  plan: typeof plan === "boolean" ? plan : undefined,
79
- planOnly: plan_only === true ? true : undefined,
80
+ planOnly: plan_only === true || plan_waits === true ? true : undefined,
80
81
  planReason:
81
82
  typeof plan_reason === "string" && plan_reason.trim()
82
83
  ? plan_reason.trim().slice(0, 200)
@@ -107,6 +108,8 @@ export async function POST(request: NextRequest) {
107
108
  id,
108
109
  ...opts,
109
110
  held,
111
+ // Another machine's plan stops for it to decide what follows.
112
+ planWaits: plan_waits === true && !!device ? true : undefined,
110
113
  startedVia: "remote",
111
114
  startedByDevice: device ?? undefined,
112
115
  });
package/app/globals.css CHANGED
@@ -110,6 +110,34 @@
110
110
  0 0 0 4px hsl(var(--primary) / 0.15);
111
111
  }
112
112
 
113
+ /* One look for every link in the chat feed: it reacts to hover, keyboard focus
114
+ and touch (:active), since a phone has no hover. */
115
+ @utility chat-link {
116
+ cursor: pointer;
117
+ overflow-wrap: anywhere;
118
+ border-radius: 0.125rem;
119
+ text-decoration-line: underline;
120
+ text-decoration-thickness: 1px;
121
+ text-underline-offset: 2px;
122
+ transition:
123
+ color 120ms,
124
+ background-color 120ms,
125
+ text-decoration-thickness 120ms;
126
+ &:hover {
127
+ color: hsl(var(--primary));
128
+ background-color: hsl(var(--primary) / 0.1);
129
+ text-decoration-thickness: 2px;
130
+ }
131
+ &:active {
132
+ color: hsl(var(--primary));
133
+ background-color: hsl(var(--primary) / 0.2);
134
+ }
135
+ &:focus-visible {
136
+ outline: 2px solid hsl(var(--ring));
137
+ outline-offset: 2px;
138
+ }
139
+ }
140
+
113
141
  @utility label-mono {
114
142
  font-family: var(--font-geist-mono);
115
143
  font-size: 0.6875rem;
@@ -62,7 +62,7 @@ import { NeedsYouStrip } from "@/components/Orchestrator/NeedsYouStrip";
62
62
  import { useResolveQuestion } from "@/data/orchestrators";
63
63
  import { needsYouFocus, needsYouFocusActions } from "@/stores/needsYouFocus";
64
64
  import { hrefWithSession } from "@/lib/session-url";
65
- import { isQuestionTool } from "@/lib/chat/turns";
65
+ import { questionSource } from "@/lib/chat/turns";
66
66
  import { useViewport } from "@/hooks/useViewport";
67
67
  import { useChatCommands } from "./useChatCommands";
68
68
  import { copySelection } from "./copy-selection";
@@ -751,16 +751,7 @@ export function ChatPanel({
751
751
  )
752
752
  );
753
753
  window.dispatchEvent(new PopStateEvent("popstate"));
754
- } else
755
- goTo((all) => {
756
- const asked = all.findLast(
757
- (i) =>
758
- isQuestionTool(i) &&
759
- (i as { input?: { text?: string } }).input?.text ===
760
- item.question.text
761
- );
762
- return asked?.id ?? null;
763
- });
754
+ } else goTo((all) => questionSource(all, item.question.text));
764
755
  }}
765
756
  onReply={(item) => {
766
757
  setReplyTo({ id: item.question.id, text: item.question.text });
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { ReactNode } from "react";
4
- import { ChevronRight, Loader2, MessageCircleQuestion } from "lucide-react";
4
+ import { ChevronRight, Loader2 } from "lucide-react";
5
5
  import type { ChatItem } from "@/lib/chat/events";
6
6
  import type { EventTurn } from "@/lib/chat/turns";
7
7
  import { cn } from "@/lib/utils";
@@ -60,12 +60,6 @@ export function EventTurnRow({
60
60
  <span className="text-foreground/80 max-w-[40%] min-w-0 shrink-0 truncate font-medium">
61
61
  {turn.task}
62
62
  </span>
63
- {turn.hasQuestion && (
64
- <MessageCircleQuestion
65
- className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400"
66
- aria-label="Asked a question"
67
- />
68
- )}
69
63
  <span className="min-w-0 flex-1 truncate">{turn.summary}</span>
70
64
  {turn.live && (
71
65
  <Loader2
@@ -49,6 +49,7 @@ const components: Components = {
49
49
  href={local ? imageUrl(href) : href}
50
50
  target="_blank"
51
51
  rel="noreferrer"
52
+ className="chat-link"
52
53
  >
53
54
  {children}
54
55
  </a>
@@ -42,7 +42,7 @@ function UserLink({ href }: { href: string }) {
42
42
  target={id ? undefined : "_blank"}
43
43
  rel={id ? undefined : "noopener noreferrer"}
44
44
  onClick={id ? (e) => openSessionLink(e, id) : undefined}
45
- className="font-medium [overflow-wrap:anywhere] underline underline-offset-2"
45
+ className="chat-link font-medium"
46
46
  >
47
47
  {href}
48
48
  </a>
@@ -2,6 +2,7 @@
2
2
  // brings what already exists in line with it.
3
3
 
4
4
  import { db } from "../db";
5
+ import { isRemoteHost } from "../hosts";
5
6
  import { reapplyChatModes } from "../chat/settings";
6
7
  import { approvePlanNow } from "../models/plan-step";
7
8
  import { autoApproveAsk, type AskRow } from "../orchestrator/asks";
@@ -28,10 +29,16 @@ async function sweep(): Promise<void> {
28
29
  }
29
30
  const plans = db
30
31
  .prepare(
31
- `SELECT id FROM sessions WHERE plan_phase = 'awaiting' AND task_status = 'running'`
32
+ `SELECT id, host_id, plan_waits_for_caller FROM sessions WHERE plan_phase = 'awaiting' AND task_status = 'running'`
32
33
  )
33
- .all() as { id: string }[];
34
+ .all() as {
35
+ id: string;
36
+ host_id: string;
37
+ plan_waits_for_caller: number | null;
38
+ }[];
34
39
  for (const p of plans) {
40
+ // A plan placed here by another machine is that machine's to decide.
41
+ if (p.plan_waits_for_caller && !isRemoteHost(p.host_id)) continue;
35
42
  try {
36
43
  await approvePlanNow(p.id, undefined, "no-approvals");
37
44
  } catch (e) {
@@ -149,6 +149,10 @@ export type ChatItem =
149
149
  // Saved since messages were tagged where they're sent: without an
150
150
  // origin, the reader typed it. Older items are matched by their text.
151
151
  tagged?: true;
152
+ // Whether it was sent while a turn ran: the agent then reads it once
153
+ // that turn ends, so what follows until then is still that turn's.
154
+ // Always set when saved; absent only on history from before it was.
155
+ midTurn?: boolean;
152
156
  // The provider's id for this message, to undo file changes back to it.
153
157
  checkpoint?: string;
154
158
  })
package/lib/chat/group.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ChatItem } from "./events";
2
+ import { readingOrder } from "./reading-order";
2
3
  import { eventTurnAt, type EventTurn } from "./turns";
3
4
 
4
5
  export type ToolItem = Extract<ChatItem, { kind: "tool" }>;
@@ -23,9 +24,10 @@ export const STANDALONE_TOOLS = new Set(["Skill", "Task", "Agent"]);
23
24
  // collapsible block, so a long turn reads as what was said, not every step.
24
25
  // Undone messages fold into one block behind the undo that took them back.
25
26
  export function groupTimeline(
26
- items: ChatItem[],
27
+ all: ChatItem[],
27
28
  opts: GroupOptions = {}
28
29
  ): TimelineBlock[] {
30
+ const items = opts.foldEvents ? readingOrder(all) : all;
29
31
  const blocks: TimelineBlock[] = [];
30
32
  let tools: ToolItem[] = [];
31
33
  const flush = () => {
@@ -0,0 +1,50 @@
1
+ import type { ChatItem } from "./events";
2
+ import { activityOrigin } from "./turns";
3
+
4
+ // Activity sent while a turn runs is saved when sent but read by the agent
5
+ // once that turn ends. It moves to after the turn's end, so the turn a
6
+ // person's message started stays whole and the activity gets its own reply.
7
+ //
8
+ // Several waiting at one turn's end are taken a turn each: the first starts
9
+ // after the end, the next after the following one. Whether the agent
10
+ // answers them together or apart isn't recorded, and this way a turn each
11
+ // keeps every reply with its own event; answered together, the first takes
12
+ // the reply and the rest show as bare rows.
13
+ //
14
+ // A message saved with `midTurn` is trusted either way. Only history from
15
+ // before the field is guessed, from whether a turn was open when it landed.
16
+ export function readingOrder(items: ChatItem[]): ChatItem[] {
17
+ const out: ChatItem[] = [];
18
+ const waiting: ChatItem[] = [];
19
+ let open = false;
20
+ let moved = false;
21
+ const release = () => out.push(...waiting.splice(0));
22
+ for (const item of items) {
23
+ if (item.kind === "user") {
24
+ const waits = item.midTurn === undefined ? open : item.midTurn;
25
+ if (waits && activityOrigin(item)) {
26
+ waiting.push(item);
27
+ moved = true;
28
+ continue;
29
+ }
30
+ release();
31
+ out.push(item);
32
+ open = true;
33
+ } else if (item.kind === "turn_end") {
34
+ out.push(item);
35
+ open = false;
36
+ const next = waiting.shift();
37
+ if (next) out.push(next);
38
+ } else if (
39
+ item.kind === "undo" ||
40
+ item.kind === "error" ||
41
+ item.kind === "command_output"
42
+ ) {
43
+ release();
44
+ out.push(item);
45
+ open = false;
46
+ } else out.push(item);
47
+ }
48
+ release();
49
+ return moved ? out : items;
50
+ }
package/lib/chat/turns.ts CHANGED
@@ -18,8 +18,6 @@ export interface EventTurn {
18
18
  at: number;
19
19
  // Still being answered.
20
20
  live: boolean;
21
- // The reply opened a question for the reader.
22
- hasQuestion: boolean;
23
21
  }
24
22
 
25
23
  const FOLDED_KINDS = new Set(["event", "peer", "schedule", "system"]);
@@ -39,6 +37,26 @@ export function activityOrigin(item: UserItem): ChatOrigin | null {
39
37
  export const isQuestionTool = (item: ChatItem): boolean =>
40
38
  item.kind === "tool" && /(^|__)question$/.test(item.name);
41
39
 
40
+ export const isAskTool = (item: ChatItem): boolean =>
41
+ item.kind === "tool" && /(^|__)ask_saad$/.test(item.name);
42
+
43
+ // The assistant message a question was asked in (its turn's last words
44
+ // before the call), else the call itself.
45
+ export function questionSource(items: ChatItem[], text: string): string | null {
46
+ const at = items.findLastIndex(
47
+ (i) =>
48
+ isQuestionTool(i) &&
49
+ (i as { input?: { text?: string } }).input?.text === text
50
+ );
51
+ if (at === -1) return null;
52
+ for (let i = at - 1; i >= 0; i--) {
53
+ const item = items[i];
54
+ if (item.kind === "user" || item.kind === "turn_end") break;
55
+ if (item.kind === "assistant" && item.text.trim()) return item.id;
56
+ }
57
+ return items[at].id;
58
+ }
59
+
42
60
  export function plainSummary(text: string, max = 140): string {
43
61
  const line =
44
62
  text
@@ -89,6 +107,8 @@ export function eventTurnAt(
89
107
  (i) =>
90
108
  i.kind === "plan" ||
91
109
  i.kind === "error" ||
110
+ isQuestionTool(i) ||
111
+ isAskTool(i) ||
92
112
  (i.kind === "approval" && i.status === "pending")
93
113
  );
94
114
  if (needsReader) return null;
@@ -105,7 +125,6 @@ export function eventTurnAt(
105
125
  task: taskName(origin),
106
126
  at: user.createdAt,
107
127
  live: !ended && end >= items.length,
108
- hasQuestion: replies.some(isQuestionTool),
109
128
  },
110
129
  };
111
130
  }
@@ -564,6 +564,8 @@ export class ChatHost {
564
564
  this.sent.add(m.id);
565
565
  this.currentTurn = m.id;
566
566
  this.hardStopAt = 0;
567
+ // A message sent now stops the turn and is read next: nothing waits.
568
+ const midTurn = this.busy() && !now;
567
569
  if (this.busy()) this.behind++;
568
570
  // Sent now, it stops the running turn, which still ends after this.
569
571
  if (!this.busy()) now = false;
@@ -599,6 +601,7 @@ export class ChatHost {
599
601
  peer: m.peer,
600
602
  origin: m.origin,
601
603
  tagged: true as const,
604
+ midTurn,
602
605
  createdAt: Date.now(),
603
606
  checkpoint,
604
607
  };
@@ -0,0 +1,19 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261015T1600_plan_waits_for_caller",
5
+ up: (db) => {
6
+ // A plan another machine placed here: it stops at the plan for that
7
+ // machine to decide what comes next. On the machine that placed it, the
8
+ // approval's message waits for the move home. Additive.
9
+ const add = (col: string, type: string) => {
10
+ const columns = db.prepare(`PRAGMA table_info(sessions)`).all() as {
11
+ name: string;
12
+ }[];
13
+ if (!columns.some((c) => c.name === col))
14
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${col} ${type}`);
15
+ };
16
+ add("plan_waits_for_caller", "INTEGER");
17
+ add("plan_handoff_message", "TEXT");
18
+ },
19
+ } satisfies Migration;
package/lib/db/types.ts CHANGED
@@ -20,7 +20,7 @@ export interface Session {
20
20
  model_overrides?: string | null;
21
21
  // A task's plan step: planning on the planning model, then implementing.
22
22
  // 'awaiting': a plan-only task's plan is written; it waits for approval.
23
- plan_phase?: "planning" | "awaiting" | "implementing" | null;
23
+ plan_phase?: "planning" | "awaiting" | "handoff" | "implementing" | null;
24
24
  plan_only?: number | null;
25
25
  // The orchestrator session that started this one; null for a person.
26
26
  started_by?: string | null;
@@ -156,6 +156,11 @@ export interface Session {
156
156
  held_for_name?: string | null;
157
157
  // A held task's "plan written" line, for its orchestrator to read.
158
158
  plan_note?: string | null;
159
+ // The task's plan stops for the machine that placed it here to decide what
160
+ // comes next (1); on that machine's mirror, the approval's message waits for
161
+ // the move home.
162
+ plan_waits_for_caller?: number | null;
163
+ plan_handoff_message?: string | null;
159
164
  // Orchestration fields
160
165
  conductor_session_id: string | null;
161
166
  worker_task: string | null;
package/lib/env-setup.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
11
+ import { gitOk } from "./git-exec";
11
12
  import { bringDependencies, runCommand } from "./worktree-deps";
12
13
  import { inside, realExisting } from "./worktree-deps/paths";
13
14
  import { seedBuildCaches } from "./worktree-deps/caches";
@@ -40,16 +41,20 @@ export interface SetupResult {
40
41
  durationMs: number;
41
42
  }
42
43
 
44
+ // A gitignored CLAUDE.md holds the project's private notes. Only one the
45
+ // worktree confirms is ignored is copied: any other would be committable.
46
+ const PRIVATE_NOTES = "CLAUDE.md";
47
+
43
48
  /**
44
- * Find env files to copy (excludes .env.example)
49
+ * Find env files to copy (excludes .env.example), and the private notes
45
50
  */
46
51
  export function findEnvFiles(projectPath: string): string[] {
47
52
  try {
48
53
  const files = fs.readdirSync(projectPath);
49
54
  return files.filter(
50
55
  (f) =>
51
- f.startsWith(".env") &&
52
- !f.endsWith(".example") &&
56
+ ((f.startsWith(".env") && !f.endsWith(".example")) ||
57
+ f === PRIVATE_NOTES) &&
53
58
  fs.statSync(path.join(projectPath, f)).isFile()
54
59
  );
55
60
  } catch {
@@ -58,7 +63,7 @@ export function findEnvFiles(projectPath: string): string[] {
58
63
  }
59
64
 
60
65
  /**
61
- * Copy env files from source to worktree
66
+ * Copy env files (and the private CLAUDE.md) from source to worktree
62
67
  */
63
68
  export async function copyEnvFiles(
64
69
  sourcePath: string,
@@ -71,6 +76,14 @@ export async function copyEnvFiles(
71
76
  try {
72
77
  const src = path.join(sourcePath, file);
73
78
  const dest = path.join(worktreePath, file);
79
+ const there = await fs.promises.lstat(dest).catch(() => null);
80
+ if (file === PRIVATE_NOTES) {
81
+ if (there || !(await gitOk(worktreePath, ["check-ignore", "-q", file])))
82
+ continue;
83
+ } else if (there?.isSymbolicLink()) {
84
+ // Never written through: a committed link could point outside.
85
+ await fs.promises.unlink(dest);
86
+ }
74
87
  await fs.promises.copyFile(src, dest);
75
88
  copied.push(file);
76
89
  } catch (error) {
@@ -24,6 +24,7 @@ import { addNote } from "../orchestrator/notes";
24
24
  import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
25
25
  import { PIECES_PROMPT, foldedBrief, pieceBrief } from "../plans/pieces";
26
26
  import { orchestratorOf } from "../tasks/starter";
27
+ import { isRemoteHost } from "../hosts";
27
28
  import { refreshBase } from "../tasks/refresh-base";
28
29
  import {
29
30
  createFeature,
@@ -121,8 +122,35 @@ function piecesLine(r: NonNullable<ReturnType<typeof planFor>>): string {
121
122
  return `${r.pieces.length} pieces${folded}: ${parts.join("; ")}`;
122
123
  }
123
124
 
125
+ // The "plan written" line for a task: where the plan is, what comes next,
126
+ // its first lines and how it split. `file` is where this machine can read it.
127
+ export function planNoteFor(
128
+ s: Session,
129
+ file: string,
130
+ next: string,
131
+ again = "",
132
+ after = ""
133
+ ): string {
134
+ let head = `Task "${s.name}": no plan file was written${again}; ${next}${after}.`;
135
+ try {
136
+ const text = fs.readFileSync(file, "utf8");
137
+ const summary = planSummary(text);
138
+ head = `Task "${s.name}": plan written to ${file}; ${next}.`;
139
+ if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
140
+ const pieces = planFor(s, text);
141
+ if (pieces && pieces.pieces.length > 1) head += `\n${piecesLine(pieces)}`;
142
+ if (pieces?.folded.length)
143
+ head += `\nPlan listed ${pieces.listed} pieces, building ${pieces.pieces.length}: ${pieces.folded.map((f) => `${f.name} folded (${f.why})`).join("; ")}`;
144
+ } catch {
145
+ // No plan file: the line says so.
146
+ }
147
+ return head;
148
+ }
149
+
124
150
  // Tells the orchestrator that started a task its plan is written, once.
125
151
  function announcePlan(s: Session, ref: string | null, retried = false): void {
152
+ // The machine that placed it announces the plan, from its own copy.
153
+ if (s.plan_waits_for_caller) return;
126
154
  if (!orchestratorOf(s.started_by) && !s.held_by_device) return;
127
155
  const workspaceId = sessionWorkspace(s);
128
156
  // A task held for another machine's orchestrator can't reach it: the line
@@ -134,18 +162,7 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
134
162
  ? `implementing on ${ref}`
135
163
  : "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
136
164
  const again = retried ? " (asked once more for it first)" : "";
137
- let head = `Task "${s.name}": no plan file was written${again}; ${next}${ref ? " anyway" : ""}.`;
138
- try {
139
- const summary = planSummary(fs.readFileSync(file, "utf8"));
140
- head = `Task "${s.name}": plan written to ${file}; ${next}.`;
141
- if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
142
- const pieces = planFor(s, fs.readFileSync(file, "utf8"));
143
- if (pieces && pieces.pieces.length > 1) head += `\n${piecesLine(pieces)}`;
144
- if (pieces?.folded.length)
145
- head += `\nPlan listed ${pieces.listed} pieces, building ${pieces.pieces.length}: ${pieces.folded.map((f) => `${f.name} folded (${f.why})`).join("; ")}`;
146
- } catch {
147
- // No plan file: the line says so.
148
- }
165
+ const head = planNoteFor(s, file, next, again, ref ? " anyway" : "");
149
166
  if (s.held_by_device) {
150
167
  db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(
151
168
  head,
@@ -244,7 +261,10 @@ async function startImplementing(
244
261
  ? undefined
245
262
  : fresh.note &&
246
263
  `Note: ${fresh.note}. Rebase onto the latest base before you open the PR.`;
247
- let split = planText ? splitOf(s, planText) : null;
264
+ // A plan another machine placed here was checked there, against its
265
+ // projects; this one's workspace doesn't know them.
266
+ let split =
267
+ planText && !s.plan_waits_for_caller ? splitOf(s, planText) : null;
248
268
  const workspaceId = sessionWorkspace(s);
249
269
  // Approved despite pieces that couldn't be registered: built as one piece,
250
270
  // and told so, rather than guessing which repository each belongs to.
@@ -441,9 +461,12 @@ export async function handoffAfterPlan(
441
461
  forget(sessionId);
442
462
  // No approvals mode doesn't stop at the plan to wait for one. Pieces that
443
463
  // couldn't be registered always do: building on a guess is the bug.
444
- const auto = !!s.plan_only && noApprovalsOn();
464
+ const auto = !!s.plan_only && noApprovalsOn() && !s.plan_waits_for_caller;
445
465
  const planText = readPlan(s);
446
- const pieceErrors = (planText ? splitOf(s, planText)?.errors : null) ?? [];
466
+ const pieceErrors =
467
+ (planText && !s.plan_waits_for_caller
468
+ ? splitOf(s, planText)?.errors
469
+ : null) ?? [];
447
470
  if ((!s.plan_only || auto) && !pieceErrors.length) {
448
471
  const ref = await startImplementing(
449
472
  s,
@@ -482,14 +505,17 @@ export async function handoffAfterPlan(
482
505
  s.id,
483
506
  `Plan of "${s.name}": couldn't register its pieces: ${pieceErrors.join("; ")}. It is held, not building. Message the task to fix the plan, or approve_plan to build it as one piece.`
484
507
  );
508
+ // The machine that placed a plan sends its own push.
485
509
  try {
486
- const text = readPlan(s);
487
- const planned = text ? planFor(s, text) : null;
488
- (await import("../notify/push/events")).pushPlanAwaiting(
489
- s.id,
490
- s.name,
491
- planned?.pieces.length ?? 1
492
- );
510
+ if (!s.plan_waits_for_caller) {
511
+ const text = readPlan(s);
512
+ const planned = text ? planFor(s, text) : null;
513
+ (await import("../notify/push/events")).pushPlanAwaiting(
514
+ s.id,
515
+ s.name,
516
+ planned?.pieces.length ?? 1
517
+ );
518
+ }
493
519
  } catch (e) {
494
520
  console.error("plan push failed:", e);
495
521
  }
@@ -560,9 +586,12 @@ export async function approvePlan(
560
586
  );
561
587
  const text = message?.trim() || undefined;
562
588
  const workspaceId = sessionWorkspace(s);
589
+ // A plan another machine placed here is approved by that machine, which
590
+ // keeps the brief its review reads.
591
+ const placedHere = !!s.plan_waits_for_caller && !isRemoteHost(s.host_id);
563
592
  // Every approval is on the brief, so the review judges the PR as the
564
593
  // implementation of the plan, not as plan-only work.
565
- if (workspaceId) {
594
+ if (workspaceId && !placedHere) {
566
595
  if (text) {
567
596
  amendBrief(workspaceId, s.id, text);
568
597
  addNote(
@@ -571,7 +600,11 @@ export async function approvePlan(
571
600
  );
572
601
  } else amendBrief(workspaceId, s.id, APPROVAL_TEXT[by], "approval");
573
602
  }
574
- const ref = await startImplementing(s, "awaiting", send, text);
603
+ // On another machine, the plan was written there: this machine decides
604
+ // where it is built, and the approval follows it.
605
+ const ref = isRemoteHost(s.host_id)
606
+ ? await (await import("../tasks/remote-plan")).continueRemotePlan(s, text)
607
+ : await startImplementing(s, "awaiting", send, text);
575
608
  if (!ref) throw new Error(`${s.name} isn't waiting for plan approval`);
576
609
  if (by === "no-approvals")
577
610
  recordAutoApproval({
@@ -583,3 +616,21 @@ export async function approvePlan(
583
616
  });
584
617
  return { ref, amendments: scopeChangesOf(s.id).length };
585
618
  }
619
+
620
+ // A plan that moved here to be built (remote-plan.ts): implementation starts
621
+ // on this machine, once, with the approval's change if it had one.
622
+ export async function implementArrived(
623
+ sessionId: string,
624
+ message?: string
625
+ ): Promise<string | null> {
626
+ const s = getSession(sessionId);
627
+ if (!s || s.plan_phase !== "awaiting" || s.task_status !== "running")
628
+ throw new Error(`${s?.name ?? "That task"} isn't waiting for a plan`);
629
+ const { sendQueuedNow } = await import("../chat/runner");
630
+ return startImplementing(
631
+ s,
632
+ "awaiting",
633
+ (id, m) => sendQueuedNow(id, m),
634
+ message
635
+ );
636
+ }