@saadnvd1/agent-os 0.3.631 → 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.
- package/app/api/tasks/[id]/approve-plan/route.ts +6 -1
- package/app/api/tasks/[id]/export/route.ts +3 -2
- package/app/api/tasks/[id]/plan/route.ts +37 -0
- package/app/api/tasks/route.ts +4 -1
- package/app/globals.css +28 -0
- package/components/Chat/ChatPanel.tsx +2 -11
- package/components/Chat/EventTurns.tsx +1 -7
- package/components/Chat/Markdown.tsx +1 -0
- package/components/Chat/Messages.tsx +1 -1
- package/lib/approvals/enable.ts +9 -2
- package/lib/chat/events.ts +4 -0
- package/lib/chat/group.ts +3 -1
- package/lib/chat/reading-order.ts +50 -0
- package/lib/chat/turns.ts +22 -3
- package/lib/chat/worker/host.ts +3 -0
- package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
- package/lib/db/types.ts +6 -1
- package/lib/env-setup.ts +17 -4
- package/lib/models/plan-step.ts +75 -24
- package/lib/tasks/held.ts +23 -0
- package/lib/tasks/hold-checks.ts +134 -0
- package/lib/tasks/holds.ts +281 -0
- package/lib/tasks/import.ts +11 -3
- package/lib/tasks/index.ts +25 -3
- package/lib/tasks/move-bundle.ts +17 -2
- package/lib/tasks/move-guard.ts +9 -1
- package/lib/tasks/move.ts +5 -4
- package/lib/tasks/pause.ts +2 -31
- package/lib/tasks/placement.ts +16 -16
- package/lib/tasks/plan-view.ts +11 -3
- package/lib/tasks/queue-candidates.ts +151 -0
- package/lib/tasks/queue-placement.ts +155 -0
- package/lib/tasks/queue-rows.ts +236 -0
- package/lib/tasks/queue-start.ts +211 -0
- package/lib/tasks/queue-tick.ts +148 -0
- package/lib/tasks/queue-types.ts +141 -0
- package/lib/tasks/queue-view.ts +112 -0
- package/lib/tasks/queue.ts +53 -1096
- package/lib/tasks/remote-plan-path.ts +7 -0
- package/lib/tasks/remote-plan.ts +308 -0
- package/lib/tasks/remote.ts +18 -1
- package/lib/tasks/schedule.ts +44 -147
- package/package.json +1 -1
- package/scripts/check +55 -7
- 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(
|
|
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
|
|
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
|
+
}
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
@@ -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
|
|
45
|
+
className="chat-link font-medium"
|
|
46
46
|
>
|
|
47
47
|
{href}
|
|
48
48
|
</a>
|
package/lib/approvals/enable.ts
CHANGED
|
@@ -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 {
|
|
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) {
|
package/lib/chat/events.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
+
}
|