@saadnvd1/agent-os 0.3.715 → 0.3.723

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 (142) hide show
  1. package/README.md +1 -0
  2. package/app/api/agent-roles/route.test.ts +67 -0
  3. package/app/api/agent-roles/route.ts +61 -0
  4. package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
  5. package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
  6. package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
  7. package/app/api/analyst/[id]/review/route.ts +22 -0
  8. package/app/api/analyst/[id]/route.ts +44 -0
  9. package/app/api/analyst/route.ts +10 -0
  10. package/app/api/bus/spawn/route.ts +22 -1
  11. package/app/api/models/availability/route.ts +9 -0
  12. package/app/api/models/choices/route.ts +9 -0
  13. package/app/api/sessions/[id]/analyst/route.ts +11 -0
  14. package/app/api/sessions/[id]/crew/route.ts +38 -0
  15. package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
  16. package/app/api/sessions/[id]/export/route.test.ts +125 -0
  17. package/app/api/sessions/[id]/export/route.ts +34 -0
  18. package/app/api/sessions/[id]/model/route.ts +49 -0
  19. package/app/api/sessions/route.ts +16 -0
  20. package/app/page.tsx +5 -0
  21. package/components/Chat/Messages.tsx +104 -10
  22. package/components/Draft/DraftChips.tsx +6 -1
  23. package/components/Draft/DraftPanel.tsx +51 -2
  24. package/components/Draft/ModelUnavailableDialog.tsx +78 -0
  25. package/components/Draft/RoleChips.tsx +110 -0
  26. package/components/Export/ExportActions.tsx +136 -0
  27. package/components/Export/ExportSheet.tsx +217 -0
  28. package/components/Export/RangePicker.tsx +144 -0
  29. package/components/Export/Seg.tsx +38 -0
  30. package/components/Export/TurnSelectBar.tsx +45 -0
  31. package/components/Models/AgentRoleCard.tsx +114 -0
  32. package/components/Models/AgentRolesSection.tsx +132 -0
  33. package/components/Models/ModelBadges.tsx +48 -0
  34. package/components/Models/ModelsSection.tsx +4 -2
  35. package/components/Models/RoleModelSelect.tsx +87 -0
  36. package/components/Pane/DesktopTabBar.tsx +2 -0
  37. package/components/Pane/MobileTabBar.tsx +12 -0
  38. package/components/Pane/PaneMenu.tsx +10 -0
  39. package/components/SessionList/SessionRow.tsx +18 -7
  40. package/components/SessionList/SessionRowMenu.tsx +6 -0
  41. package/components/Sessions/RolePill.tsx +196 -0
  42. package/components/Settings/SettingsDialog.tsx +9 -0
  43. package/components/Share/ShareDialog.tsx +13 -0
  44. package/data/analyst/index.ts +3 -0
  45. package/data/analyst/keys.ts +6 -0
  46. package/data/analyst/mutations.ts +116 -0
  47. package/data/analyst/queries.ts +35 -0
  48. package/data/exports/index.ts +2 -0
  49. package/data/exports/keys.ts +3 -0
  50. package/data/exports/queries.ts +51 -0
  51. package/data/models/index.ts +98 -0
  52. package/data/push/topics.ts +5 -0
  53. package/data/sessions/queries.ts +26 -0
  54. package/lib/agents/brief.ts +1 -0
  55. package/lib/analyst/__fixtures__/items.ts +57 -0
  56. package/lib/analyst/__fixtures__/mocks.ts +49 -0
  57. package/lib/analyst/brief.ts +17 -0
  58. package/lib/analyst/buildDigest.ts +204 -0
  59. package/lib/analyst/createLink.ts +98 -0
  60. package/lib/analyst/endLink.ts +31 -0
  61. package/lib/analyst/forward.ts +109 -0
  62. package/lib/analyst/index.ts +16 -0
  63. package/lib/analyst/linkView.ts +38 -0
  64. package/lib/analyst/onTurnEnd.ts +119 -0
  65. package/lib/analyst/parseReview.ts +41 -0
  66. package/lib/analyst/pushDigest.ts +143 -0
  67. package/lib/analyst/resume.ts +35 -0
  68. package/lib/analyst/route.ts +50 -0
  69. package/lib/analyst/signals.ts +121 -0
  70. package/lib/analyst/store.ts +303 -0
  71. package/lib/analyst/types.ts +140 -0
  72. package/lib/bus/condense.ts +5 -1
  73. package/lib/bus/index.ts +5 -0
  74. package/lib/bus/read-scope.ts +12 -0
  75. package/lib/bus/role-alias.ts +70 -0
  76. package/lib/bus/targets.ts +3 -0
  77. package/lib/chat/drivers/opencode-mapper.ts +55 -1
  78. package/lib/chat/drivers/pi-approvals.ts +7 -4
  79. package/lib/chat/drivers/pi-mapper.ts +38 -0
  80. package/lib/chat/drivers/pi-text-tools.ts +104 -0
  81. package/lib/chat/drivers/pi.ts +8 -3
  82. package/lib/chat/drivers/turn-items.ts +36 -11
  83. package/lib/chat/drivers/turn-outcome.ts +108 -0
  84. package/lib/chat/events.ts +8 -0
  85. package/lib/chat/registry.ts +13 -0
  86. package/lib/chat/store.ts +19 -0
  87. package/lib/chat/turns.ts +1 -0
  88. package/lib/chat/worker/extras.ts +4 -2
  89. package/lib/claims/collectLocal.ts +3 -1
  90. package/lib/db/changes.ts +6 -0
  91. package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
  92. package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
  93. package/lib/db/session-parts/columns.ts +2 -0
  94. package/lib/db/types.ts +3 -0
  95. package/lib/done/archive.ts +2 -0
  96. package/lib/drafts.ts +4 -0
  97. package/lib/export/build.ts +470 -0
  98. package/lib/export/compact.ts +156 -0
  99. package/lib/export/estimate.ts +83 -0
  100. package/lib/export/fixtures.ts +45 -0
  101. package/lib/export/html.tsx +195 -0
  102. package/lib/export/json.ts +39 -0
  103. package/lib/export/load.ts +64 -0
  104. package/lib/export/markdown.ts +127 -0
  105. package/lib/export/request.ts +36 -0
  106. package/lib/export/service.ts +142 -0
  107. package/lib/export/summary.ts +86 -0
  108. package/lib/export/turns.ts +63 -0
  109. package/lib/export/view.ts +173 -0
  110. package/lib/models/agent-binding.ts +102 -0
  111. package/lib/models/agent-roles-view.ts +157 -0
  112. package/lib/models/agent-roles.ts +87 -0
  113. package/lib/models/availability-refs.ts +17 -0
  114. package/lib/models/availability.ts +91 -0
  115. package/lib/models/catalog.ts +156 -0
  116. package/lib/models/config.ts +27 -0
  117. package/lib/models/locality.ts +33 -0
  118. package/lib/models/providers.ts +11 -0
  119. package/lib/models/resolve.ts +50 -12
  120. package/lib/models/roles.ts +12 -0
  121. package/lib/needs-you.ts +1 -1
  122. package/lib/orchestrator/facts.ts +7 -2
  123. package/lib/orchestrator/read.ts +5 -1
  124. package/lib/orchestrator/untrusted.ts +5 -0
  125. package/lib/projects.ts +1 -0
  126. package/lib/session-artifacts/agentos-files.ts +94 -0
  127. package/lib/session-artifacts/collect.ts +17 -0
  128. package/lib/sessions/crew.ts +72 -0
  129. package/lib/sessions/launch.ts +49 -9
  130. package/lib/settings-url.ts +1 -0
  131. package/lib/share/markdown.ts +2 -2
  132. package/lib/share/redact.ts +16 -10
  133. package/lib/share/render.tsx +1 -1
  134. package/lib/share/snapshot.ts +2 -2
  135. package/lib/sidebar/row-menu.ts +6 -1
  136. package/lib/tasks/index.ts +6 -0
  137. package/lib/worktree-deps/caches.ts +3 -2
  138. package/lib/worktree-deps/clone.ts +3 -1
  139. package/lib/worktree-deps/paths.ts +26 -0
  140. package/package.json +1 -1
  141. package/server.ts +9 -1
  142. package/stores/exportUi.ts +87 -0
@@ -32,6 +32,7 @@ import {
32
32
  type ItemRow,
33
33
  type RunRow,
34
34
  } from "./derive";
35
+ import { agentosSignature, fromAgentosDirs, fromKept } from "./agentos-files";
35
36
  import { keepFiles, readManifest, relInside, sessionKeptDir } from "./keep";
36
37
 
37
38
  export const MAX_FILES = 500;
@@ -160,6 +161,16 @@ async function build(session: Session): Promise<Entry[]> {
160
161
  }
161
162
 
162
163
  const manifest = worktree && !worktreeThere ? readManifest(session.id) : null;
164
+ if (worktree) {
165
+ const known = new Set(candidates.map((c) => c.key));
166
+ candidates.push(
167
+ ...(worktreeThere
168
+ ? fromAgentosDirs(worktree, known)
169
+ : manifest
170
+ ? fromKept(worktree, manifest.kept, sqliteMs(manifest.at), known)
171
+ : [])
172
+ );
173
+ }
163
174
  if (worktree) {
164
175
  const planFile = path.join(worktree, PLAN_FILE);
165
176
  const named = candidates.some((c) => c.key === `file:${planFile}`);
@@ -255,6 +266,9 @@ interface Cached {
255
266
  }
256
267
  const cache = new Map<string, Cached>();
257
268
 
269
+ const isLocalWorktree = (s: Session) =>
270
+ !!s.worktree_path && (!s.host_id || s.host_id === "local");
271
+
258
272
  function signature(session: Session): string {
259
273
  const row = db
260
274
  .prepare(`SELECT max(seq) AS seq FROM chat_items WHERE session_id = ?`)
@@ -265,6 +279,9 @@ function signature(session: Session): string {
265
279
  session.pr_number,
266
280
  session.worktree_path,
267
281
  session.updated_at,
282
+ isLocalWorktree(session)
283
+ ? agentosSignature(expand(session.worktree_path!))
284
+ : "",
268
285
  ].join("|");
269
286
  }
270
287
 
@@ -0,0 +1,72 @@
1
+ /**
2
+ * A crew: the sessions started in roles that work together. Its id is the
3
+ * session that began it, so a crew needs no table of its own.
4
+ */
5
+
6
+ import { db } from "../db";
7
+ import type { AgentRole } from "../models/agent-roles";
8
+
9
+ const crewOf = (id: string | null | undefined): string | null =>
10
+ id
11
+ ? ((
12
+ db.prepare(`SELECT crew_id FROM session_rows WHERE id = ?`).get(id) as
13
+ { crew_id: string | null } | undefined
14
+ )?.crew_id ?? null)
15
+ : null;
16
+
17
+ // Puts a session in a role and a crew: the one named, else its starter's (or
18
+ // its calling session's), else a new one it begins.
19
+ export function joinCrew(
20
+ id: string,
21
+ role: AgentRole,
22
+ from: {
23
+ crewId?: string | null;
24
+ startedBy?: string | null;
25
+ parent?: string | null;
26
+ }
27
+ ): string {
28
+ const crew =
29
+ from.crewId || crewOf(from.startedBy) || crewOf(from.parent) || id;
30
+ db.prepare(
31
+ `UPDATE sessions SET agent_role = ?, crew_id = ? WHERE id = ?`
32
+ ).run(role, crew, id);
33
+ return crew;
34
+ }
35
+
36
+ export interface CrewMember {
37
+ id: string;
38
+ name: string;
39
+ agentRole: string | null;
40
+ model: string | null;
41
+ modelProvider: string | null;
42
+ agentType: string;
43
+ taskStatus: string | null;
44
+ }
45
+
46
+ export function crewMembers(crewId: string): CrewMember[] {
47
+ return (
48
+ db
49
+ .prepare(
50
+ `SELECT id, name, agent_role, model, model_provider, agent_type, task_status
51
+ FROM session_rows WHERE crew_id = ? AND archived_at IS NULL
52
+ ORDER BY created_at`
53
+ )
54
+ .all(crewId) as {
55
+ id: string;
56
+ name: string;
57
+ agent_role: string | null;
58
+ model: string | null;
59
+ model_provider: string | null;
60
+ agent_type: string;
61
+ task_status: string | null;
62
+ }[]
63
+ ).map((r) => ({
64
+ id: r.id,
65
+ name: r.name,
66
+ agentRole: r.agent_role,
67
+ model: r.model,
68
+ modelProvider: r.model_provider,
69
+ agentType: r.agent_type,
70
+ taskStatus: r.task_status,
71
+ }));
72
+ }
@@ -16,6 +16,9 @@ import { discoversModels, resolveModelForAgent } from "../model-catalog";
16
16
  import { isDiscoveredModel } from "../chat/discovered-models";
17
17
  import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
18
18
  import { ANTHROPIC } from "../models/roles";
19
+ import { joinCrew } from "./crew";
20
+ import { prepareRoleStart } from "../models/agent-binding";
21
+ import { isAgentRole, roleBrief, type AgentRole } from "../models/agent-roles";
19
22
  import { getProject } from "../projects";
20
23
  import { getWorkspace } from "../workspaces";
21
24
  import { getHost, isRemoteHost } from "../hosts";
@@ -84,6 +87,13 @@ export interface LaunchInput {
84
87
  // follows the project; one in none keeps this (see inWorkspace).
85
88
  workspaceId?: string | null;
86
89
  systemPrompt?: string | null;
90
+ // Start it in a role: the model comes from the role's binding (or
91
+ // `model`), and its brief joins the instructions.
92
+ agentRole?: AgentRole | null;
93
+ // The crew it joins: the id of the session that began it.
94
+ crewId?: string | null;
95
+ // When the role's model is down, start on the role's fallback instead.
96
+ useFallback?: boolean;
87
97
  // The user's group it starts in (lib/session-groups).
88
98
  groupId?: string;
89
99
  }
@@ -261,7 +271,17 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
261
271
  if (scratch && projectPath === SCRATCH_DIR)
262
272
  await fs.promises.mkdir(SCRATCH_DIR, { recursive: true });
263
273
 
264
- const agentType = input.agentType;
274
+ if (input.agentRole && (remote || !isAgentRole(input.agentRole)))
275
+ throw new Error("A role session runs on this machine");
276
+ const roleStart = input.agentRole
277
+ ? await prepareRoleStart(input.agentRole, {
278
+ workspaceId: project?.workspace_id ?? home,
279
+ projectId: project && !project.is_uncategorized ? project.id : null,
280
+ override: input.model?.trim() || null,
281
+ useFallback: input.useFallback,
282
+ })
283
+ : null;
284
+ const agentType: AgentType = roleStart?.agentType ?? input.agentType;
265
285
  const view =
266
286
  input.view === "terminal" || !supportsChat(agentType) || remote
267
287
  ? "terminal"
@@ -273,20 +293,30 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
273
293
  const picked = input.startedBy ? parseOverride(input.model) : null;
274
294
  if (picked && typeof picked !== "string")
275
295
  throw new Error(`model: a session takes one "provider/model"`);
276
- const routed =
277
- agentType === "claude" && (picked || !input.model?.trim()) && !remote
296
+ const routed = roleStart
297
+ ? roleStart.agentType === "claude"
298
+ ? roleStart.routed
299
+ : null
300
+ : agentType === "claude" && (picked || !input.model?.trim()) && !remote
278
301
  ? resolveRole(role, {
279
302
  workspaceId: project?.workspace_id ?? home,
280
303
  override: picked,
281
304
  })
282
305
  : null;
283
- if (picked && routed && routed.provider.id !== ANTHROPIC && view !== "chat")
306
+ if (
307
+ (picked || roleStart) &&
308
+ routed &&
309
+ routed.provider.id !== ANTHROPIC &&
310
+ view !== "chat"
311
+ )
284
312
  throw new Error("Another provider's models run only as a chat");
285
313
  const interactive =
286
314
  routed && (routed.provider.id === ANTHROPIC || view === "chat")
287
315
  ? routed
288
316
  : null;
289
- const wanted = input.model?.trim() || project?.default_model;
317
+ const wanted = roleStart
318
+ ? roleStart.model
319
+ : input.model?.trim() || project?.default_model;
290
320
  // An agent without a static list runs what it reports for this folder.
291
321
  const discovered =
292
322
  !interactive &&
@@ -295,9 +325,11 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
295
325
  (await isDiscoveredModel(agentType, projectPath, wanted));
296
326
  const model = interactive
297
327
  ? interactive.model
298
- : discovered && wanted
299
- ? wanted
300
- : resolveModelForAgent(agentType, wanted);
328
+ : roleStart
329
+ ? roleStart.model
330
+ : discovered && wanted
331
+ ? wanted
332
+ : resolveModelForAgent(agentType, wanted);
301
333
  const text = input.prompt?.trim() ?? "";
302
334
  const naming: Naming = text
303
335
  ? await nameFor(text, projectPath, input.name)
@@ -318,7 +350,9 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
318
350
  cwd,
319
351
  input.parentSessionId ?? null,
320
352
  model,
321
- input.systemPrompt ?? null,
353
+ [input.systemPrompt, roleBrief(input.agentRole)]
354
+ .filter(Boolean)
355
+ .join("\n\n") || null,
322
356
  "sessions",
323
357
  agentType,
324
358
  input.autoApprove || input.access === "full" ? 1 : 0,
@@ -337,6 +371,12 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
337
371
  id
338
372
  );
339
373
  setHome(id, home);
374
+ if (input.agentRole)
375
+ joinCrew(id, input.agentRole, {
376
+ crewId: input.crewId,
377
+ startedBy: input.startedBy,
378
+ parent: input.parentSessionId,
379
+ });
340
380
  if (input.groupId) fileInGroup(id, input.groupId);
341
381
  if (interactive) setSessionModel(id, interactive);
342
382
  if (input.access)
@@ -9,6 +9,7 @@ export const SETTINGS_SECTIONS = [
9
9
  "approvals",
10
10
  "workspaces",
11
11
  "models",
12
+ "agent-roles",
12
13
  "devices",
13
14
  "shares",
14
15
  "notifications",
@@ -83,13 +83,13 @@ export function htmlToMarkdown(html: string, demote = 0): string {
83
83
  }
84
84
 
85
85
  /** A fenced block whose fence is longer than any backtick run inside. */
86
- function fenced(text: string): string {
86
+ export function fenced(text: string, lang = ""): string {
87
87
  const longest = Math.max(
88
88
  2,
89
89
  ...(text.match(/`+/g) ?? []).map((m) => m.length)
90
90
  );
91
91
  const fence = "`".repeat(longest + 1);
92
- return `${fence}\n${text}\n${fence}`;
92
+ return `${fence}${lang}\n${text}\n${fence}`;
93
93
  }
94
94
 
95
95
  function itemToMarkdown(item: SnapshotItem): string {
@@ -97,10 +97,12 @@ function sub(
97
97
 
98
98
  export function redactForShare(
99
99
  input: string,
100
- ctx: ShareContext = NO_CONTEXT
100
+ ctx: ShareContext = NO_CONTEXT,
101
+ opts: { only?: Kind[] } = {}
101
102
  ): { text: string; hits: Hits } {
102
103
  const hits = noHits();
103
104
  let t = input;
105
+ const on = (k: Kind) => !opts.only || opts.only.includes(k);
104
106
 
105
107
  // 1. Credentials by shape.
106
108
  t = sub(t, PRIVATE_KEY_BLOCK, SECRET, hits, "secret");
@@ -143,7 +145,7 @@ export function redactForShare(
143
145
  }
144
146
 
145
147
  // 3. Home paths, and the account name before an @.
146
- if (ctx.home.length > 1)
148
+ if (on("path") && ctx.home.length > 1)
147
149
  t = sub(
148
150
  t,
149
151
  new RegExp(escape(ctx.home) + "(?![\\w.-])", "g"),
@@ -151,8 +153,8 @@ export function redactForShare(
151
153
  hits,
152
154
  "path"
153
155
  );
154
- t = sub(t, new RegExp(HOME.source, "g"), "~", hits, "path");
155
- if (ctx.user.length > 2)
156
+ if (on("path")) t = sub(t, new RegExp(HOME.source, "g"), "~", hits, "path");
157
+ if (on("path") && ctx.user.length > 2)
156
158
  t = sub(
157
159
  t,
158
160
  new RegExp(`\\b${escape(ctx.user)}(?=@)`, "g"),
@@ -162,14 +164,16 @@ export function redactForShare(
162
164
  );
163
165
 
164
166
  // 4. Addresses.
165
- t = sub(t, IPV4, "[ip]", hits, "ip", (m) => KEEP_IPV4.has(m));
166
- t = sub(t, IPV6, "[ip]", hits, "ip", (m) => KEEP_IPV6.has(m));
167
+ if (on("ip")) {
168
+ t = sub(t, IPV4, "[ip]", hits, "ip", (m) => KEEP_IPV4.has(m));
169
+ t = sub(t, IPV6, "[ip]", hits, "ip", (m) => KEEP_IPV6.has(m));
170
+ }
167
171
 
168
172
  // 5. This machine's names, and private-network suffixes.
169
173
  const names = [...new Set(ctx.hosts.filter((h) => h.length > 2))].sort(
170
174
  (a, b) => b.length - a.length
171
175
  );
172
- if (names.length)
176
+ if (on("host") && names.length)
173
177
  t = sub(
174
178
  t,
175
179
  new RegExp(
@@ -180,11 +184,13 @@ export function redactForShare(
180
184
  hits,
181
185
  "host"
182
186
  );
183
- t = sub(t, CONNECT_HOST, "[host]", hits, "host");
184
- t = sub(t, LOCAL_HOST, "[host]", hits, "host");
187
+ if (on("host")) {
188
+ t = sub(t, CONNECT_HOST, "[host]", hits, "host");
189
+ t = sub(t, LOCAL_HOST, "[host]", hits, "host");
190
+ }
185
191
 
186
192
  // 6. Email addresses.
187
- t = sub(t, EMAIL, "[email]", hits, "email");
193
+ if (on("email")) t = sub(t, EMAIL, "[email]", hits, "email");
188
194
 
189
195
  return { text: t, hits };
190
196
  }
@@ -60,7 +60,7 @@ const components: Components = {
60
60
  img: ({ alt }) => <span>[image{alt ? `: ${alt}` : ""}]</span>,
61
61
  };
62
62
 
63
- function Markdown({ text }: { text: string }) {
63
+ export function Markdown({ text }: { text: string }) {
64
64
  return (
65
65
  <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
66
66
  {text}
@@ -44,7 +44,7 @@ export interface Snapshot {
44
44
 
45
45
  export const MAX_TOOL_INPUT = 4_000;
46
46
  export const MAX_TOOL_OUTPUT = 20_000;
47
- const HIDDEN_FILE = "[contents of a secret file hidden]";
47
+ export const HIDDEN_FILE = "[contents of a secret file hidden]";
48
48
  const NO_IMAGE = "[image not shared]";
49
49
 
50
50
  const clip = (s: string, max: number) =>
@@ -55,7 +55,7 @@ const clip = (s: string, max: number) =>
55
55
  const excerptOf = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 120);
56
56
 
57
57
  // Every string in a tool's input, to see whether it names a secret file.
58
- function strings(v: unknown, out: string[] = []): string[] {
58
+ export function strings(v: unknown, out: string[] = []): string[] {
59
59
  if (typeof v === "string") out.push(v);
60
60
  else if (Array.isArray(v)) v.forEach((x) => strings(x, out));
61
61
  else if (v && typeof v === "object")
@@ -16,6 +16,7 @@ export type RowMenuItem =
16
16
  | "open-recipe"
17
17
  | "copy-link"
18
18
  | "share"
19
+ | "export"
19
20
  | "fork"
20
21
  | "fresh-start"
21
22
  | "new-conversation"
@@ -121,7 +122,11 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
121
122
  input.hasCard && "card",
122
123
  input.recipeRun && "open-recipe",
123
124
  ]),
124
- ...group("share", ["copy-link", s.view === "chat" && "share"]),
125
+ ...group("share", [
126
+ "copy-link",
127
+ s.view === "chat" && "share",
128
+ s.view === "chat" && "export",
129
+ ]),
125
130
  ...group("restart", [
126
131
  s.agent_type === "claude" && agentRow && live && "fork",
127
132
  agentRow && live && "fresh-start",
@@ -67,6 +67,7 @@ import { saidSinceLastMessage } from "../chat/store";
67
67
 
68
68
  import { lastActiveOf } from "./last-active";
69
69
  import { setPlan, setTask } from "../db/session-parts";
70
+ import { joinCrew } from "../sessions/crew";
70
71
  export * from "./state";
71
72
  export { codeReviewGate, codeReviewRefusal } from "./code-review";
72
73
  export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
@@ -414,6 +415,11 @@ async function startTask(
414
415
  startedVia: opts.startedVia,
415
416
  startedByDevice: opts.held?.device ?? opts.startedByDevice,
416
417
  });
418
+ // A task is the Developer of its crew.
419
+ joinCrew(id, "developer", {
420
+ startedBy: opts.startedBy,
421
+ parent: opts.parentSessionId,
422
+ });
417
423
  } catch (error) {
418
424
  // The claim was made for a session that never existed.
419
425
  if (opts.pieceId) releaseClaim(opts.pieceId, id);
@@ -21,7 +21,7 @@ import { promisify } from "util";
21
21
  import { gitResult, isGitError } from "../git-exec";
22
22
  import { findNodeModules } from "./clone";
23
23
  import { fastCopyMode } from "./fastcopy";
24
- import { inside, realExisting } from "./paths";
24
+ import { STAGING_SUFFIX, ignoreStaging, inside, realExisting } from "./paths";
25
25
  import { copyTree } from "./spare";
26
26
  import type { CopyMode } from "./spare";
27
27
 
@@ -234,7 +234,8 @@ export async function seedBuildCaches(opts: {
234
234
  skip(rel, "its folder in the worktree resolves outside it");
235
235
  continue;
236
236
  }
237
- const partial = `${dest}.aos-partial`;
237
+ const partial = `${dest}${STAGING_SUFFIX}`;
238
+ await ignoreStaging(dir);
238
239
  await fs.promises.rm(partial, { recursive: true, force: true });
239
240
  const ok =
240
241
  (await copyTree(real, partial, mode)) &&
@@ -10,6 +10,7 @@ import { createHash } from "crypto";
10
10
  import * as fs from "fs";
11
11
  import * as path from "path";
12
12
  import { LOCKFILES } from "./install";
13
+ import { STAGING_SUFFIX, ignoreStaging } from "./paths";
13
14
  import { STORE_ROOT, fastCopyMode, unlinkMutable } from "./fastcopy";
14
15
  import type { FastMode } from "./fastcopy";
15
16
  import {
@@ -79,7 +80,8 @@ async function cloneWhole(
79
80
  target: string,
80
81
  mode: FastMode
81
82
  ): Promise<boolean> {
82
- const partial = `${target}.aos-partial`;
83
+ const partial = `${target}${STAGING_SUFFIX}`;
84
+ await ignoreStaging(path.dirname(target));
83
85
  await fs.promises.rm(partial, { recursive: true, force: true });
84
86
  const ok =
85
87
  (await copyTree(from, partial, mode)) && (await takeSpare(partial, target));
@@ -6,6 +6,7 @@
6
6
 
7
7
  import * as fs from "fs";
8
8
  import * as path from "path";
9
+ import { git } from "../git-exec";
9
10
 
10
11
  export const inside = (root: string, p: string) =>
11
12
  p === root || p.startsWith(root + path.sep);
@@ -18,3 +19,28 @@ export async function realExisting(p: string): Promise<string> {
18
19
  if (path.dirname(at) === at) return at;
19
20
  }
20
21
  }
22
+
23
+ export const STAGING_SUFFIX = ".aos-partial";
24
+
25
+ const EXCLUDE_LINE = `*${STAGING_SUFFIX}`;
26
+
27
+ // Staging dirs (`node_modules.aos-partial`) sit beside their target, where git
28
+ // would list them as untracked work. Written once to the repo's shared
29
+ // info/exclude, so it covers every worktree and no .gitignore is touched.
30
+ // Best effort: outside a repository there is nothing to ignore it in.
31
+ export async function ignoreStaging(dir: string): Promise<void> {
32
+ try {
33
+ const common = (
34
+ await git(dir, ["rev-parse", "--git-common-dir"], { timeout: 5000 })
35
+ ).trim();
36
+ if (!common) return;
37
+ const file = path.join(path.resolve(dir, common), "info", "exclude");
38
+ const current = await fs.promises.readFile(file, "utf8").catch(() => "");
39
+ if (current.split(/\r?\n/).some((l) => l.trim() === EXCLUDE_LINE)) return;
40
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
41
+ const lead = current && !current.endsWith("\n") ? "\n" : "";
42
+ await fs.promises.appendFile(file, `${lead}${EXCLUDE_LINE}\n`);
43
+ } catch {
44
+ // never fails a setup
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.715",
3
+ "version": "0.3.723",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/server.ts CHANGED
@@ -6,6 +6,8 @@ import next from "next";
6
6
  import { WebSocketServer, WebSocket } from "ws";
7
7
  import { ensureBusBrief } from "./lib/agents/launch";
8
8
  import { reattachChats } from "./lib/chat/runner";
9
+ import { setTurnEndHook } from "./lib/chat/registry";
10
+ import { analystTurnEndHook, resumeAnalystLinks } from "./lib/analyst";
9
11
  import { serveChatSocket } from "./lib/chat/socket";
10
12
  import { startStackWatcher } from "./lib/stacks";
11
13
  import { startPauseWatcher } from "./lib/tasks/pause";
@@ -224,6 +226,8 @@ app.prepare().then(async () => {
224
226
  const folder = sessionFolder(id);
225
227
  if (folder) refreshGitSoon(folder);
226
228
  });
229
+ // An analyst hears each of its coding agent's turns as they end.
230
+ setTurnEndHook(analystTurnEndHook);
227
231
  // Terminal sessions are watched through tmux control mode, or tapped with
228
232
  // pipe-pane where tmux is too old for it.
229
233
  const control = await startTmuxControl();
@@ -453,7 +457,11 @@ app.prepare().then(async () => {
453
457
  void sweepSims();
454
458
  setInterval(sweepSims, 5 * 60_000);
455
459
  // Chat turns that kept running through a restart.
456
- void reattachChats().finally(() => startPauseWatcher());
460
+ void reattachChats().finally(() => {
461
+ startPauseWatcher();
462
+ // Digests a restart cut off, and turns that ended while it was down.
463
+ void resumeAnalystLinks();
464
+ });
457
465
  // Older chats get into the search index in the background, a batch at a time.
458
466
  if (process.env.AGENTOS_SEARCH_INDEX !== "off") startConversationBackfill();
459
467
  // Reviews this restart cut off run again and deliver their verdicts.
@@ -0,0 +1,87 @@
1
+ import { proxy } from "valtio";
2
+ import {
3
+ DEFAULT_REQUEST,
4
+ type ExportRequest,
5
+ type ExportTurnInfo,
6
+ } from "@/lib/export/view";
7
+
8
+ // The export sheet, and picking turns in the chat for it. The request lives
9
+ // here so it survives closing the sheet to select turns.
10
+ export const exportUi = proxy<{
11
+ open: boolean;
12
+ sessionId: string | null;
13
+ title: string;
14
+ req: ExportRequest;
15
+ /** Open at the turn that began with this message, to the end. */
16
+ fromItemId: string | null;
17
+ /** The session's turns, as the last estimate listed them. */
18
+ turns: ExportTurnInfo[];
19
+ selecting: boolean;
20
+ pick: { from: number | null; to: number | null };
21
+ }>({
22
+ open: false,
23
+ sessionId: null,
24
+ title: "",
25
+ req: { ...DEFAULT_REQUEST },
26
+ fromItemId: null,
27
+ turns: [],
28
+ selecting: false,
29
+ pick: { from: null, to: null },
30
+ });
31
+
32
+ export const exportUiActions = {
33
+ open(sessionId: string, title: string, fromItemId: string | null = null) {
34
+ if (exportUi.sessionId !== sessionId) {
35
+ exportUi.turns = [];
36
+ exportUi.req = { ...exportUi.req, range: undefined };
37
+ }
38
+ exportUi.sessionId = sessionId;
39
+ exportUi.title = title;
40
+ exportUi.fromItemId = fromItemId;
41
+ exportUi.open = true;
42
+ },
43
+ close() {
44
+ exportUi.open = false;
45
+ exportUi.selecting = false;
46
+ exportUi.fromItemId = null;
47
+ },
48
+ setRequest(patch: Partial<ExportRequest>) {
49
+ exportUi.req = { ...exportUi.req, ...patch };
50
+ },
51
+ /** Close the sheet and let the chat's user messages be ticked. */
52
+ startSelect(from: number | null = null) {
53
+ exportUi.open = false;
54
+ exportUi.selecting = true;
55
+ exportUi.pick = { from, to: null };
56
+ },
57
+ /** A tap on a turn: the first sets the start, the second the end. */
58
+ tap(n: number) {
59
+ const { from, to } = exportUi.pick;
60
+ exportUi.pick =
61
+ from === null || to !== null ? { from: n, to: null } : { from, to: n };
62
+ },
63
+ /** Use the ticked turns as the range and go back to the sheet. */
64
+ finishSelect() {
65
+ const { from, to } = exportUi.pick;
66
+ if (from !== null) {
67
+ const end = to ?? from;
68
+ exportUi.req = {
69
+ ...exportUi.req,
70
+ range: { from: Math.min(from, end), to: Math.max(from, end) },
71
+ };
72
+ }
73
+ exportUi.selecting = false;
74
+ exportUi.open = true;
75
+ },
76
+ cancelSelect() {
77
+ exportUi.selecting = false;
78
+ exportUi.open = true;
79
+ },
80
+ };
81
+
82
+ /** The ticked turns, ordered: null until something is ticked. */
83
+ export function pickedRange(pick: { from: number | null; to: number | null }) {
84
+ if (pick.from === null) return null;
85
+ const end = pick.to ?? pick.from;
86
+ return { from: Math.min(pick.from, end), to: Math.max(pick.from, end) };
87
+ }