@saadnvd1/agent-os 0.3.828 → 0.3.836

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 (39) hide show
  1. package/README.md +2 -0
  2. package/app/api/sessions/route.ts +7 -1
  3. package/app/globals.css +14 -0
  4. package/app/layout.tsx +3 -0
  5. package/components/Chat/ChatPanel.tsx +110 -27
  6. package/components/Chat/EventRow.tsx +15 -1
  7. package/components/Chat/ForkResults.tsx +109 -0
  8. package/components/Chat/Forks.tsx +69 -103
  9. package/components/Chat/Messages.tsx +14 -0
  10. package/components/Chat/QuoteButton.tsx +59 -21
  11. package/components/Chat/TimelineRows.tsx +1 -0
  12. package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
  13. package/components/Chat/composer/ComposerBody.tsx +23 -2
  14. package/components/Chat/composer/ForkHeader.tsx +66 -0
  15. package/components/Chat/composer/Toolbar.tsx +43 -7
  16. package/components/Chat/fork-draft.test.ts +57 -0
  17. package/components/Chat/fork-draft.ts +34 -0
  18. package/components/Chat/quote-selection.ts +6 -2
  19. package/content/whats-new.json +24 -0
  20. package/data/fork/index.ts +6 -1
  21. package/lib/chat/commands.ts +0 -15
  22. package/lib/chat/events.ts +3 -0
  23. package/lib/chat/socket.ts +7 -3
  24. package/lib/chat/start-error.ts +13 -0
  25. package/lib/chat/worker/cwd.ts +77 -0
  26. package/lib/chat/worker/host.ts +11 -2
  27. package/lib/chat/worker/main.ts +11 -2
  28. package/lib/db/types.ts +2 -0
  29. package/lib/fork/brief.ts +4 -2
  30. package/lib/fork/deliver.ts +6 -1
  31. package/lib/fork/ledger.ts +5 -0
  32. package/lib/fork/note.ts +6 -0
  33. package/lib/fork/settle.ts +8 -4
  34. package/lib/fork/start.ts +6 -2
  35. package/lib/fork/types.ts +7 -1
  36. package/lib/fork/view.ts +18 -2
  37. package/package.json +1 -1
  38. package/scripts/lib/build-swap.sh +1 -1
  39. package/scripts/update-e2e.test.ts +2 -0
@@ -0,0 +1,13 @@
1
+ // What a person reads when a chat's worker can't start. The raw error (a
2
+ // socket ENOENT, usually) stays in the server log.
3
+ export function startFailureMessage(error: unknown): string {
4
+ const raw = error instanceof Error ? error.message : String(error);
5
+ if (!/^The chat worker didn't start/.test(raw)) return raw;
6
+ const reason = /ENOENT|ECONNREFUSED/.test(raw)
7
+ ? "it stopped while starting"
8
+ : raw
9
+ .replace(/^The chat worker didn't start:?\s*/, "")
10
+ .split("\n")[0]
11
+ .slice(0, 120) || "it didn't answer";
12
+ return `Couldn't start the agent: ${reason}`;
13
+ }
@@ -0,0 +1,77 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { getProject } from "../../projects";
5
+ import type { Session } from "../../db";
6
+
7
+ const expand = (p: string) => p.replace(/^~/, os.homedir());
8
+ const isDir = (p: string) => {
9
+ try {
10
+ return fs.statSync(p).isDirectory();
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+
16
+ export const WORKTREE_REMOVED_NOTE =
17
+ "(This task's PR merged and its worktree was removed. You are now running in the project's main checkout: do not edit files or commit here. Questions are fine; if more changes are needed, say a follow-up task is required.)";
18
+
19
+ // The project's own checkout, for a session whose folder is gone; null when
20
+ // that is missing too or the session never had a project.
21
+ function projectRoot(session: Session): string | null {
22
+ const project = session.project_id ? getProject(session.project_id) : null;
23
+ const root = project ? expand(project.working_directory) : null;
24
+ return root && isDir(root) ? root : null;
25
+ }
26
+
27
+ // Where a session's agent runs. A task's worktree is removed when it
28
+ // merges, but its chat can still be asked a question: it runs in the
29
+ // project's checkout instead.
30
+ export function resolveChatCwd(session: Session): {
31
+ session: Session;
32
+ removedFrom: string | null;
33
+ } {
34
+ const dir = expand(session.working_directory);
35
+ if (isDir(dir)) return { session, removedFrom: null };
36
+ const root = projectRoot(session);
37
+ if (!root) return { session, removedFrom: null };
38
+ return {
39
+ session: { ...session, working_directory: root },
40
+ removedFrom: dir,
41
+ };
42
+ }
43
+
44
+ // Claude finds a conversation under its folder's name in its config dir, so
45
+ // a resume from a new folder needs the transcript copied there.
46
+ export function carryClaudeTranscript(
47
+ from: string,
48
+ to: string,
49
+ claudeSessionId: string | null
50
+ ): void {
51
+ if (!claudeSessionId) return;
52
+ const base = path.join(
53
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"),
54
+ "projects"
55
+ );
56
+ const name = (d: string) => d.replace(/[^a-zA-Z0-9]/g, "-");
57
+ const src = path.join(base, name(from), `${claudeSessionId}.jsonl`);
58
+ const dest = path.join(base, name(to), `${claudeSessionId}.jsonl`);
59
+ try {
60
+ if (!fs.existsSync(src) || fs.existsSync(dest)) return;
61
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
62
+ fs.copyFileSync(src, dest);
63
+ } catch (error) {
64
+ console.error(
65
+ "Couldn't carry the transcript to the project folder:",
66
+ error
67
+ );
68
+ }
69
+ }
70
+
71
+ // A finished task whose worktree is gone: the sidebar's cue for the notice.
72
+ export function worktreeRemoved(session: Session): boolean {
73
+ if (!session.worktree_path || !session.task_status) return false;
74
+ if (session.task_status === "running") return false;
75
+ if (session.host_id && session.host_id !== "local") return false;
76
+ return !isDir(expand(session.worktree_path));
77
+ }
@@ -8,6 +8,7 @@ import os from "os";
8
8
  import { db, type Session } from "../../db";
9
9
  import { agentEnv } from "../../agents/launch";
10
10
  import { forkBrief } from "../../fork/extras";
11
+ import { WORKTREE_REMOVED_NOTE } from "./cwd";
11
12
  import { sessionRunningBrief } from "../../project-config/session";
12
13
  import { BUS_BRIEF } from "../../agents/brief";
13
14
  import { resolveModelForAgent } from "../../model-catalog";
@@ -196,7 +197,9 @@ export class ChatHost {
196
197
  private session: Session,
197
198
  private emit: (e: WorkerEvent) => void,
198
199
  // What a session's role adds: its brief and its own tools.
199
- extras: RoleExtras = {}
200
+ extras: RoleExtras = {},
201
+ // Its worktree was removed: it runs in the project's checkout.
202
+ private worktreeGone = false
200
203
  ) {
201
204
  this.gate = new GuestGate((note) => {
202
205
  if (!this.busy()) return;
@@ -611,7 +614,13 @@ export class ChatHost {
611
614
  if (handOver) this.stopsUpTo = this.turnsStarted;
612
615
  if (now) this.nowPending++;
613
616
  this.stopping = null;
614
- const note = this.refreshedNote ?? (this.restartedNote && RESTARTED_NOTE);
617
+ const note =
618
+ [
619
+ this.worktreeGone && WORKTREE_REMOVED_NOTE,
620
+ this.refreshedNote ?? (this.restartedNote && RESTARTED_NOTE),
621
+ ]
622
+ .filter(Boolean)
623
+ .join("\n") || null;
615
624
  const text = agentText(note ? `${note}\n\n${m.text}` : m.text, m.files);
616
625
  if (this.refreshedNote) {
617
626
  this.refreshedNote = null;
@@ -41,9 +41,17 @@ async function main(sessionId: string) {
41
41
  console.error(`No session ${sessionId}`);
42
42
  process.exit(1);
43
43
  }
44
- const session = await (
44
+ const routed = await (
45
45
  await import("../../models/launch")
46
46
  ).routeOrchestrator(row);
47
+ const { resolveChatCwd, carryClaudeTranscript } = await import("./cwd");
48
+ const { session, removedFrom } = resolveChatCwd(routed);
49
+ if (removedFrom && session.agent_type === "claude")
50
+ carryClaudeTranscript(
51
+ removedFrom,
52
+ session.working_directory,
53
+ session.claude_session_id
54
+ );
47
55
 
48
56
  const clients = new Set<net.Socket>();
49
57
  const emit = (e: WorkerEvent) => {
@@ -60,7 +68,8 @@ async function main(sessionId: string) {
60
68
  emit(e);
61
69
  if (e.type === "state") armIdle();
62
70
  },
63
- extras
71
+ extras,
72
+ !!removedFrom
64
73
  );
65
74
  function armIdle() {
66
75
  clearTimeout(idleTimer);
package/lib/db/types.ts CHANGED
@@ -87,6 +87,8 @@ export interface Session {
87
87
  // Task priority (null reads as normal) and timed pause (lib/tasks/pause.ts):
88
88
  // pausing, paused or resuming, until when (null: until resumed).
89
89
  priority?: string | null;
90
+ // Set only by GET /api/sessions: a finished task whose worktree is gone.
91
+ worktree_removed?: boolean;
90
92
  pause_state?: "pausing" | "paused" | "resuming" | null;
91
93
  paused_until?: string | null;
92
94
  pause_id?: number | null;
package/lib/fork/brief.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { FORK_LIMITS } from "./options";
4
4
  import type { ChildRow } from "./types";
5
5
 
6
- export const PARENT_BRIEF = `Forks: when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own throwaway checkout). Fork only work that doesn't depend on your next step.`;
6
+ export const PARENT_BRIEF = `Forks: when the user asks you to fork, or to do things in parallel in background copies, run \`aos fork "<instruction>" ...\` (one instruction per fork), keep working, and results arrive as a message. Also when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own throwaway checkout). Fork only work that doesn't depend on your next step.`;
7
7
 
8
8
  export const CHILD_BRIEF = `You are a fork child: a background copy of another session, started to do one piece of work and report back. You have its abilities, not its checkout: don't change the parent's files, push, open a PR or fork. Put temp work (scripts, repros, notes) in $AOS_SCRATCH. Run builds and tests only through \`aos heavy -- <command>\`, which queues you with the other forks. To ask the parent something, \`aos send <parent> "<question>"\`; that doesn't finish you. When done, \`aos return "<result>"\` (first line: the answer in a sentence; then the detail; \`--failed\` if you couldn't do it). Your reply when you end your turn without it is used as the result, after a short wait.`;
9
9
 
@@ -41,7 +41,9 @@ const outcomeWord = (c: ChildRow) =>
41
41
  ? "failed"
42
42
  : c.outcome === "cancelled"
43
43
  ? "cancelled"
44
- : "done";
44
+ : c.outcome === "empty"
45
+ ? "ended with no result"
46
+ : "done";
45
47
 
46
48
  // One delivery: every result in the batch, whole when short, else its
47
49
  // summary and the command that reads the rest.
@@ -34,7 +34,12 @@ async function send(parentId: string, deliveryId: string, rows: ChildRow[]) {
34
34
  text,
35
35
  queue: true,
36
36
  from: "AgentOS",
37
- origin: { kind: "event", label: "Forks", body },
37
+ origin: {
38
+ kind: "event",
39
+ label: "Forks",
40
+ body,
41
+ forks: { parent: parentId, ids: rows.map((c) => c.child_session_id) },
42
+ },
38
43
  });
39
44
  markDelivered(deliveryId);
40
45
  }
@@ -176,6 +176,11 @@ export function summaryOf(text: string): string {
176
176
  return one.length <= max ? one : `${one.slice(0, max - 1)}…`;
177
177
  }
178
178
 
179
+ // A result that says nothing: blank, or one bare flag (`--help`) a child took
180
+ // for its answer. A quoted sentence that starts with dashes is still an answer.
181
+ export const isNonAnswer = (text: string | null | undefined): boolean =>
182
+ !text?.trim() || /^--?[a-z][a-z-]*$/i.test(text.trim());
183
+
179
184
  export const capResult = (text: string): string =>
180
185
  text.length <= FORK_LIMITS.resultChars
181
186
  ? text
@@ -0,0 +1,6 @@
1
+ /** The one line the parent's chat keeps where forks were started. */
2
+
3
+ export const FORK_NOTE_PREFIX = "fork-";
4
+
5
+ export const startedText = (ns: number[], labels: string[]): string =>
6
+ `${ns.length === 1 ? `Fork ${ns[0]}` : `Forks ${ns.join(", ")}`} started · ${labels.join(" · ")}`;
@@ -22,6 +22,7 @@ import {
22
22
  updateResult,
23
23
  childrenOf,
24
24
  findChild,
25
+ isNonAnswer,
25
26
  markDeliveredIds,
26
27
  } from "./ledger";
27
28
  import { FORK_LIMITS } from "./options";
@@ -120,7 +121,11 @@ export async function returnChild(
120
121
  if (!row) throw new Error("Only a fork child can return a result");
121
122
  const body = text.trim();
122
123
  if (!body) throw new Error('Give the result: aos return "<result>"');
123
- const outcome: ChildOutcome = failed ? "failed" : "ok";
124
+ const outcome: ChildOutcome = failed
125
+ ? "failed"
126
+ : isNonAnswer(body)
127
+ ? "empty"
128
+ : "ok";
124
129
  if (
125
130
  await settleAndDeliver(childId, {
126
131
  outcome,
@@ -205,10 +210,9 @@ export async function lookAtChild(row: ChildRow, now: number): Promise<void> {
205
210
  // It asked its parent something and is waiting for the answer.
206
211
  if (openWaits().some((w) => w.waiter_id === row.child_session_id)) return;
207
212
  const reply = lastReply(row.child_session_id);
208
- if (!reply) return;
209
213
  await settleAndDeliver(row.child_session_id, {
210
- outcome: "ok",
211
- summary: summaryOf(reply),
214
+ outcome: isNonAnswer(reply) ? "empty" : "ok",
215
+ summary: reply ? summaryOf(reply) : "ended without a result",
212
216
  result: reply,
213
217
  kind: "implicit",
214
218
  });
package/lib/fork/start.ts CHANGED
@@ -18,6 +18,7 @@ import { getProject } from "../projects";
18
18
  import { limitOf, runningIn, startingIn } from "../tasks/queue-rows";
19
19
  import { currentLevel, loadEnabled } from "../load/monitor";
20
20
  import { childFirstMessage } from "./brief";
21
+ import { FORK_NOTE_PREFIX, startedText } from "./note";
21
22
  import { ForkError, forkConversation } from "./conversation";
22
23
  import {
23
24
  childrenOf,
@@ -120,7 +121,7 @@ export function startFork(req: ForkRequest): ForkStarted {
120
121
  instruction,
121
122
  worktree,
122
123
  }));
123
- const noteId = `fork-${groupId}`;
124
+ const noteId = `${FORK_NOTE_PREFIX}${groupId}`;
124
125
  insertGroup({
125
126
  id: groupId,
126
127
  parentId: parent.id,
@@ -136,7 +137,10 @@ export function startFork(req: ForkRequest): ForkStarted {
136
137
  kind: "note" as const,
137
138
  tone: "note" as const,
138
139
  createdAt: Date.now(),
139
- text: `Forked ${children.length === 1 ? "1 copy" : `${children.length} copies`}: ${children.map((c) => c.label).join(" · ")}`,
140
+ text: startedText(
141
+ children.map((c) => numberOf(parent.id, c.childId)),
142
+ children.map((c) => c.label)
143
+ ),
140
144
  };
141
145
  saveItem(parent.id, item);
142
146
  emit(parent.id, { type: "item", item });
package/lib/fork/types.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export type ChildState =
2
2
  "queued" | "starting" | "running" | "needs_input" | "settled";
3
3
 
4
- export type ChildOutcome = "ok" | "failed" | "cancelled";
4
+ // `empty`: it ended, but with nothing to read (blank, or a bare flag like
5
+ // "--help"), so it isn't shown as a finished answer.
6
+ export type ChildOutcome = "ok" | "failed" | "cancelled" | "empty";
5
7
 
6
8
  // `implicit`: the child's turn ended without `aos return`, so its final
7
9
  // answer stands in (kept apart so stats can tell them).
@@ -59,6 +61,8 @@ export interface ChildView {
59
61
  hold: string | null;
60
62
  outcome: ChildOutcome | null;
61
63
  summary: string | null;
64
+ // The start of its result, for the card in the parent's chat.
65
+ excerpt: string | null;
62
66
  resultKind: ResultKind | null;
63
67
  worktree: boolean;
64
68
  fellBack: boolean;
@@ -67,6 +71,8 @@ export interface ChildView {
67
71
  delivered: boolean;
68
72
  // A question or approval it's waiting on.
69
73
  asks: string | null;
74
+ // What it is doing now (its latest tool), while it runs.
75
+ activity: string | null;
70
76
  }
71
77
 
72
78
  export interface GroupView {
package/lib/fork/view.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** What clients and `aos children` show, and the numbers behind them. */
2
2
 
3
+ import { lastItems } from "../chat/store";
3
4
  import { childrenOf, getGroup } from "./ledger";
4
5
  import type { ChildRow, ChildView, GroupView } from "./types";
5
6
 
@@ -13,6 +14,7 @@ export const childView = (c: ChildRow): ChildView => ({
13
14
  hold: c.hold,
14
15
  outcome: c.outcome,
15
16
  summary: c.summary,
17
+ excerpt: c.result ? c.result.slice(0, 600) : null,
16
18
  resultKind: c.result_kind,
17
19
  worktree: !!c.worktree,
18
20
  fellBack: !!c.fell_back,
@@ -20,8 +22,16 @@ export const childView = (c: ChildRow): ChildView => ({
20
22
  settledAt: c.settled_at,
21
23
  delivered: c.delivered_at != null,
22
24
  asks: c.state === "needs_input" ? c.hold : null,
25
+ activity: null,
23
26
  });
24
27
 
28
+ // The title of the latest tool a running child used, to say what it is doing.
29
+ const activityOf = (c: ChildRow): string | null => {
30
+ if (c.state !== "running") return null;
31
+ const tool = lastItems(c.child_session_id, 1, "tool")[0];
32
+ return tool && tool.kind === "tool" ? tool.title : null;
33
+ };
34
+
25
35
  export function childrenView(parentId: string): {
26
36
  children: ChildView[];
27
37
  groups: GroupView[];
@@ -44,7 +54,11 @@ export function childrenView(parentId: string): {
44
54
  });
45
55
  }
46
56
  return {
47
- children: rows.map((c, i) => ({ ...childView(c), n: i + 1 })),
57
+ children: rows.map((c, i) => ({
58
+ ...childView(c),
59
+ n: i + 1,
60
+ activity: activityOf(c),
61
+ })),
48
62
  groups,
49
63
  };
50
64
  }
@@ -61,6 +75,7 @@ export interface ForkStats {
61
75
  ok: number;
62
76
  failed: number;
63
77
  cancelled: number;
78
+ empty: number;
64
79
  implicit: number;
65
80
  fellBack: number;
66
81
  medianSeconds: number | null;
@@ -83,6 +98,7 @@ export function statsOf(rows: ChildRow[]): ForkStats {
83
98
  ok: done.filter((c) => c.outcome === "ok").length,
84
99
  failed: done.filter((c) => c.outcome === "failed").length,
85
100
  cancelled: done.filter((c) => c.outcome === "cancelled").length,
101
+ empty: done.filter((c) => c.outcome === "empty").length,
86
102
  implicit: done.filter(
87
103
  (c) => c.result_kind === "implicit" && c.outcome === "ok"
88
104
  ).length,
@@ -106,7 +122,7 @@ export function statsText(s: ForkStats): string {
106
122
  const pct = (n: number) =>
107
123
  s.settled ? `${Math.round((n / s.settled) * 100)}%` : "-";
108
124
  return [
109
- `${s.children} children, ${s.settled} settled (${s.ok} ok, ${s.failed} failed, ${s.cancelled} cancelled)`,
125
+ `${s.children} children, ${s.settled} settled (${s.ok} ok, ${s.failed} failed, ${s.cancelled} cancelled, ${s.empty} no result)`,
110
126
  `returned without aos return: ${s.implicit} (${pct(s.implicit)}); started from a brief, not a fork: ${s.fellBack}`,
111
127
  `median time to settle: ${s.medianSeconds ?? "-"}s`,
112
128
  `tokens: ${s.tokensIn} in, ${s.tokensOut} out, ${s.cacheRead} cache read`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.828",
3
+ "version": "0.3.836",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -8,7 +8,7 @@
8
8
  # Sourced by scripts/redeploy; run from the repository root.
9
9
 
10
10
  # shellcheck source=generated.sh
11
- source scripts/lib/generated.sh
11
+ source "$(dirname "${BASH_SOURCE[0]}")/generated.sh"
12
12
 
13
13
  LIVE=.next
14
14
  NEXT_BUILD=.next-build
@@ -19,6 +19,8 @@ const SCRIPTS = [
19
19
  "lib/build-swap.sh",
20
20
  "lib/pnpm.sh",
21
21
  "lib/deps-swap.sh",
22
+ "lib/generated.sh",
23
+ "generated-files.txt",
22
24
  "lib/update-verify.mjs",
23
25
  ];
24
26