@saadnvd1/agent-os 0.3.700 → 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 (306) hide show
  1. package/README.md +2 -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/groups/route.ts +44 -0
  11. package/app/api/bus/spawn/route.ts +34 -2
  12. package/app/api/claims/[id]/route.ts +20 -0
  13. package/app/api/claims/check/route.ts +23 -0
  14. package/app/api/claims/check.test.ts +113 -0
  15. package/app/api/claims/exchange/route.ts +42 -0
  16. package/app/api/claims/outside.test.ts +90 -0
  17. package/app/api/claims/route.test.ts +193 -0
  18. package/app/api/claims/route.ts +73 -0
  19. package/app/api/hosts/[id]/route.ts +9 -1
  20. package/app/api/models/availability/route.ts +9 -0
  21. package/app/api/models/choices/route.ts +9 -0
  22. package/app/api/peer/unlink/route.ts +17 -0
  23. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  24. package/app/api/peers/[hostId]/route.ts +20 -0
  25. package/app/api/peers/gate.test.ts +64 -1
  26. package/app/api/peers/pending/route.test.ts +37 -0
  27. package/app/api/peers/pending/route.ts +11 -4
  28. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  29. package/app/api/session-groups/[id]/route.ts +25 -0
  30. package/app/api/session-groups/order/route.ts +15 -0
  31. package/app/api/session-groups/route.ts +21 -0
  32. package/app/api/sessions/[id]/analyst/route.ts +11 -0
  33. package/app/api/sessions/[id]/crew/route.ts +38 -0
  34. package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
  35. package/app/api/sessions/[id]/export/route.test.ts +125 -0
  36. package/app/api/sessions/[id]/export/route.ts +34 -0
  37. package/app/api/sessions/[id]/fork/route.ts +3 -0
  38. package/app/api/sessions/[id]/group/route.ts +18 -0
  39. package/app/api/sessions/[id]/model/route.ts +49 -0
  40. package/app/api/sessions/[id]/route.ts +14 -4
  41. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  42. package/app/api/sessions/claims.test.ts +122 -0
  43. package/app/api/sessions/group/route.ts +17 -0
  44. package/app/api/sessions/route.ts +31 -14
  45. package/app/page.tsx +10 -0
  46. package/components/Chat/Messages.tsx +104 -10
  47. package/components/Claims/ClaimFiles.tsx +38 -0
  48. package/components/Claims/ClaimRow.tsx +82 -0
  49. package/components/Claims/ClaimsSheet.tsx +176 -0
  50. package/components/Claims/OverlapPill.tsx +31 -0
  51. package/components/Claims/StartClaimsNotice.tsx +70 -0
  52. package/components/Claims/claims-ui.test.ts +161 -0
  53. package/components/Claims/format.ts +57 -0
  54. package/components/Claims/index.ts +3 -0
  55. package/components/Draft/DraftChips.tsx +6 -1
  56. package/components/Draft/DraftPanel.tsx +90 -3
  57. package/components/Draft/ModelUnavailableDialog.tsx +78 -0
  58. package/components/Draft/RoleChips.tsx +110 -0
  59. package/components/Export/ExportActions.tsx +136 -0
  60. package/components/Export/ExportSheet.tsx +217 -0
  61. package/components/Export/RangePicker.tsx +144 -0
  62. package/components/Export/Seg.tsx +38 -0
  63. package/components/Export/TurnSelectBar.tsx +45 -0
  64. package/components/Hosts/AccountMachines.tsx +32 -18
  65. package/components/Hosts/HostRow.tsx +155 -14
  66. package/components/Hosts/IncomingLinkSheet.tsx +28 -10
  67. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  68. package/components/Models/AgentRoleCard.tsx +114 -0
  69. package/components/Models/AgentRolesSection.tsx +132 -0
  70. package/components/Models/ModelBadges.tsx +48 -0
  71. package/components/Models/ModelsSection.tsx +4 -2
  72. package/components/Models/RoleModelSelect.tsx +87 -0
  73. package/components/Pane/DesktopTabBar.tsx +2 -0
  74. package/components/Pane/MobileTabBar.tsx +12 -0
  75. package/components/Pane/PaneMenu.tsx +10 -0
  76. package/components/SessionList/GroupShelves.tsx +195 -0
  77. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  78. package/components/SessionList/RowSubtitle.tsx +7 -1
  79. package/components/SessionList/SelectionToolbar.tsx +52 -1
  80. package/components/SessionList/SessionRow.tsx +19 -7
  81. package/components/SessionList/SessionRowMenu.tsx +23 -0
  82. package/components/SessionList/SessionShelves.tsx +5 -0
  83. package/components/SessionList/SidebarBody.tsx +6 -0
  84. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  85. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  86. package/components/SessionList/SidebarHeader.tsx +5 -0
  87. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  88. package/components/SessionList/index.tsx +18 -14
  89. package/components/SessionList/useSidebarData.ts +10 -0
  90. package/components/Sessions/RolePill.tsx +196 -0
  91. package/components/Settings/SettingsDialog.tsx +9 -0
  92. package/components/Share/ShareDialog.tsx +13 -0
  93. package/components/ui/notice.tsx +46 -0
  94. package/components/ui/status-pill.tsx +39 -0
  95. package/data/analyst/index.ts +3 -0
  96. package/data/analyst/keys.ts +6 -0
  97. package/data/analyst/mutations.ts +116 -0
  98. package/data/analyst/queries.ts +35 -0
  99. package/data/claims/index.ts +2 -0
  100. package/data/claims/keys.ts +5 -0
  101. package/data/claims/queries.ts +47 -0
  102. package/data/exports/index.ts +2 -0
  103. package/data/exports/keys.ts +3 -0
  104. package/data/exports/queries.ts +51 -0
  105. package/data/models/index.ts +98 -0
  106. package/data/peers/keys.ts +1 -0
  107. package/data/peers/queries.ts +31 -0
  108. package/data/push/topics.ts +9 -0
  109. package/data/session-groups/index.ts +156 -0
  110. package/data/sessions/index.ts +0 -1
  111. package/data/sessions/pin.ts +2 -6
  112. package/data/sessions/queries.ts +36 -29
  113. package/data/sessions/shelf.ts +2 -6
  114. package/data/tasks/index.ts +2 -0
  115. package/hooks/useSessions.ts +0 -12
  116. package/lib/__fixtures__/peer-rpc.ts +65 -33
  117. package/lib/agents/brief.ts +1 -0
  118. package/lib/agents/spawn.ts +4 -0
  119. package/lib/analyst/__fixtures__/items.ts +57 -0
  120. package/lib/analyst/__fixtures__/mocks.ts +49 -0
  121. package/lib/analyst/brief.ts +17 -0
  122. package/lib/analyst/buildDigest.ts +204 -0
  123. package/lib/analyst/createLink.ts +98 -0
  124. package/lib/analyst/endLink.ts +31 -0
  125. package/lib/analyst/forward.ts +109 -0
  126. package/lib/analyst/index.ts +16 -0
  127. package/lib/analyst/linkView.ts +38 -0
  128. package/lib/analyst/onTurnEnd.ts +119 -0
  129. package/lib/analyst/parseReview.ts +41 -0
  130. package/lib/analyst/pushDigest.ts +143 -0
  131. package/lib/analyst/resume.ts +35 -0
  132. package/lib/analyst/route.ts +50 -0
  133. package/lib/analyst/signals.ts +121 -0
  134. package/lib/analyst/store.ts +303 -0
  135. package/lib/analyst/types.ts +140 -0
  136. package/lib/bus/condense.ts +5 -1
  137. package/lib/bus/index.ts +13 -0
  138. package/lib/bus/read-scope.ts +12 -0
  139. package/lib/bus/role-alias.ts +70 -0
  140. package/lib/bus/targets.ts +3 -0
  141. package/lib/chat/drivers/opencode-mapper.ts +55 -1
  142. package/lib/chat/drivers/pi-approvals.ts +7 -4
  143. package/lib/chat/drivers/pi-mapper.ts +38 -0
  144. package/lib/chat/drivers/pi-text-tools.ts +104 -0
  145. package/lib/chat/drivers/pi.ts +8 -3
  146. package/lib/chat/drivers/turn-items.ts +36 -11
  147. package/lib/chat/drivers/turn-outcome.ts +108 -0
  148. package/lib/chat/events.ts +8 -0
  149. package/lib/chat/registry.ts +13 -0
  150. package/lib/chat/store.ts +19 -0
  151. package/lib/chat/turns.ts +1 -0
  152. package/lib/chat/worker/extras.ts +4 -2
  153. package/lib/claims/collectLocal.ts +123 -0
  154. package/lib/claims/describe.ts +41 -0
  155. package/lib/claims/exchange.ts +91 -0
  156. package/lib/claims/goal.ts +45 -0
  157. package/lib/claims/index.ts +10 -0
  158. package/lib/claims/loop.ts +51 -0
  159. package/lib/claims/machine.ts +21 -0
  160. package/lib/claims/mine.ts +43 -0
  161. package/lib/claims/notifyOverlaps.ts +101 -0
  162. package/lib/claims/onMerged.ts +85 -0
  163. package/lib/claims/outside.ts +36 -0
  164. package/lib/claims/overlap.ts +87 -0
  165. package/lib/claims/overlapCache.ts +86 -0
  166. package/lib/claims/overlapText.ts +36 -0
  167. package/lib/claims/peers.ts +56 -0
  168. package/lib/claims/startCheck.ts +100 -0
  169. package/lib/claims/startWarning.ts +29 -0
  170. package/lib/claims/store.ts +366 -0
  171. package/lib/claims/types.ts +102 -0
  172. package/lib/claims/view.ts +80 -0
  173. package/lib/claims/yield.ts +24 -0
  174. package/lib/db/changes.ts +12 -0
  175. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  176. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  177. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  178. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  179. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  180. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  181. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  182. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  183. package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
  184. package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
  185. package/lib/db/queries.ts +0 -40
  186. package/lib/db/session-parts/columns.ts +3 -0
  187. package/lib/db/types.ts +19 -8
  188. package/lib/doctor/checks.ts +64 -1
  189. package/lib/done/archive.ts +2 -0
  190. package/lib/drafts.ts +4 -0
  191. package/lib/export/build.ts +470 -0
  192. package/lib/export/compact.ts +156 -0
  193. package/lib/export/estimate.ts +83 -0
  194. package/lib/export/fixtures.ts +45 -0
  195. package/lib/export/html.tsx +195 -0
  196. package/lib/export/json.ts +39 -0
  197. package/lib/export/load.ts +64 -0
  198. package/lib/export/markdown.ts +127 -0
  199. package/lib/export/request.ts +36 -0
  200. package/lib/export/service.ts +142 -0
  201. package/lib/export/summary.ts +86 -0
  202. package/lib/export/turns.ts +63 -0
  203. package/lib/export/view.ts +173 -0
  204. package/lib/git-remote.ts +43 -0
  205. package/lib/hosts/index.ts +10 -1
  206. package/lib/models/agent-binding.ts +102 -0
  207. package/lib/models/agent-roles-view.ts +157 -0
  208. package/lib/models/agent-roles.ts +87 -0
  209. package/lib/models/availability-refs.ts +17 -0
  210. package/lib/models/availability.ts +91 -0
  211. package/lib/models/catalog.ts +156 -0
  212. package/lib/models/config.ts +27 -0
  213. package/lib/models/locality.ts +33 -0
  214. package/lib/models/providers.ts +11 -0
  215. package/lib/models/resolve.ts +50 -12
  216. package/lib/models/roles.ts +12 -0
  217. package/lib/needs-you.ts +1 -1
  218. package/lib/orchestration.ts +2 -0
  219. package/lib/orchestrator/act.ts +54 -4
  220. package/lib/orchestrator/brief.ts +2 -1
  221. package/lib/orchestrator/claim-wait.ts +71 -0
  222. package/lib/orchestrator/conditions.ts +28 -1
  223. package/lib/orchestrator/describe.ts +6 -1
  224. package/lib/orchestrator/facts.ts +12 -2
  225. package/lib/orchestrator/read.ts +5 -1
  226. package/lib/orchestrator/serve.ts +37 -2
  227. package/lib/orchestrator/tool-names.ts +1 -0
  228. package/lib/orchestrator/tool-schemas.ts +31 -1
  229. package/lib/orchestrator/tools.ts +3 -1
  230. package/lib/orchestrator/untrusted.ts +5 -0
  231. package/lib/orchestrator/watcher.ts +19 -1
  232. package/lib/peer/account.ts +74 -2
  233. package/lib/peer/allowlist.ts +1 -1
  234. package/lib/peer/client.ts +26 -1
  235. package/lib/peer/link-cli.ts +19 -4
  236. package/lib/peer/links.ts +117 -5
  237. package/lib/peer/machines.ts +14 -2
  238. package/lib/peer/name.ts +32 -0
  239. package/lib/peer/pairing.ts +34 -5
  240. package/lib/peer/pin.ts +10 -3
  241. package/lib/peer/reasons.ts +7 -0
  242. package/lib/peer/revoke.ts +75 -0
  243. package/lib/peer/rpc-server.ts +53 -5
  244. package/lib/peer/unlink.ts +62 -0
  245. package/lib/peer/unlinked-proof.ts +44 -0
  246. package/lib/projects.ts +6 -2
  247. package/lib/security/demo.ts +7 -0
  248. package/lib/session-artifacts/agentos-files.ts +94 -0
  249. package/lib/session-artifacts/collect.ts +17 -0
  250. package/lib/session-groups/assignSessions.ts +81 -0
  251. package/lib/session-groups/cli.ts +61 -0
  252. package/lib/session-groups/createGroup.ts +28 -0
  253. package/lib/session-groups/index.ts +21 -0
  254. package/lib/session-groups/removeGroup.ts +36 -0
  255. package/lib/session-groups/reorderGroups.ts +31 -0
  256. package/lib/session-groups/resolveGroup.ts +19 -0
  257. package/lib/session-groups/route.ts +32 -0
  258. package/lib/session-groups/store.ts +85 -0
  259. package/lib/session-groups/types.ts +25 -0
  260. package/lib/session-groups/updateGroup.ts +35 -0
  261. package/lib/session-groups/workspace.ts +21 -0
  262. package/lib/sessions/crew.ts +72 -0
  263. package/lib/sessions/launch.ts +54 -11
  264. package/lib/settings-url.ts +1 -0
  265. package/lib/share/markdown.ts +2 -2
  266. package/lib/share/redact.ts +16 -10
  267. package/lib/share/render.tsx +1 -1
  268. package/lib/share/snapshot.ts +2 -2
  269. package/lib/sidebar/rollup.ts +1 -0
  270. package/lib/sidebar/row-menu.ts +8 -1
  271. package/lib/sidebar/shelves.ts +90 -15
  272. package/lib/sidebar/visible-order.ts +5 -0
  273. package/lib/tasks/hold-checks.ts +3 -1
  274. package/lib/tasks/holds.ts +3 -0
  275. package/lib/tasks/index.ts +11 -0
  276. package/lib/tasks/queue-rows.ts +8 -1
  277. package/lib/tasks/queue-start.ts +1 -0
  278. package/lib/tasks/queue-types.ts +9 -1
  279. package/lib/tasks/queue.ts +15 -5
  280. package/lib/tasks/schedule-types.ts +1 -0
  281. package/lib/test-temp.ts +19 -3
  282. package/lib/workspaces.ts +11 -3
  283. package/lib/worktree-deps/caches.ts +3 -2
  284. package/lib/worktree-deps/clone.ts +3 -1
  285. package/lib/worktree-deps/paths.ts +26 -0
  286. package/lib/worktree-sweep/testTemp.ts +23 -106
  287. package/package.json +1 -1
  288. package/scripts/agent-os +1 -1
  289. package/scripts/autodeploy +2 -0
  290. package/scripts/autodeploy.test.ts +31 -1
  291. package/scripts/claim-hook.sh +31 -0
  292. package/scripts/claim-hook.test.ts +230 -0
  293. package/scripts/lib/cli-links.sh +36 -0
  294. package/scripts/lib/commands.sh +3 -0
  295. package/scripts/lib/machine.sh +27 -2
  296. package/scripts/lib/machine.test.ts +25 -3
  297. package/scripts/sync-cli-links +7 -0
  298. package/scripts/sync-cli-links.test.ts +69 -0
  299. package/server.ts +12 -1
  300. package/stores/claimsUi.ts +22 -0
  301. package/stores/exportUi.ts +87 -0
  302. package/app/api/groups/[...path]/route.ts +0 -136
  303. package/app/api/groups/route.ts +0 -93
  304. package/data/groups/index.ts +0 -1
  305. package/data/groups/mutations.ts +0 -95
  306. package/hooks/useGroups.ts +0 -37
@@ -2,6 +2,7 @@ import { randomUUID } from "crypto";
2
2
  import type { DriverEvent, UsageTotals } from "../events";
3
3
  import { openCodeTool } from "./opencode-rules";
4
4
  import { TurnItems } from "./turn-items";
5
+ import { logTurnEnd, turnOutcome } from "./turn-outcome";
5
6
 
6
7
  type P = Record<string, unknown>;
7
8
  const str = (v: unknown) => (typeof v === "string" ? v : "");
@@ -26,6 +27,15 @@ export class OpenCodeMapper {
26
27
  private current = new Set<string>();
27
28
  private past = new Set<string>();
28
29
  private error: string | null = null;
30
+ // The turn's last assistant message: its finish reason, text and calls.
31
+ private last = {
32
+ id: "",
33
+ finish: "",
34
+ provider: "",
35
+ model: "",
36
+ text: new Map<string, string>(),
37
+ calls: new Set<string>(),
38
+ };
29
39
  private aborted = false;
30
40
  // Idle from before the prompt is stale until the session goes busy.
31
41
  private admitted = false;
@@ -98,6 +108,18 @@ export class OpenCodeMapper {
98
108
  this.current.add(id);
99
109
  this.roles.set(id, str(info.role));
100
110
  if (info.role !== "assistant") return;
111
+ if (this.last.id !== id)
112
+ this.last = {
113
+ id,
114
+ finish: "",
115
+ provider: "",
116
+ model: "",
117
+ text: new Map(),
118
+ calls: new Set(),
119
+ };
120
+ this.last.finish = str(info.finish) || this.last.finish;
121
+ this.last.provider = str(info.providerID) || this.last.provider;
122
+ this.last.model = str(info.modelID) || this.last.model;
101
123
  if (info.error) this.failed(info.error as P);
102
124
  if (!(info.time as P)?.completed || this.counted.has(id)) return;
103
125
  this.counted.add(id);
@@ -125,6 +147,8 @@ export class OpenCodeMapper {
125
147
  const seen = this.parts.get(id) ?? { kind, shown: 0 };
126
148
  this.parts.set(id, seen);
127
149
  const text = str(part.text);
150
+ if (kind === "assistant" && this.last.id === str(part.messageID))
151
+ this.last.text.set(id, text);
128
152
  if (text.length > seen.shown) {
129
153
  this.items.delta(id, kind, text.slice(seen.shown));
130
154
  seen.shown = text.length;
@@ -132,7 +156,10 @@ export class OpenCodeMapper {
132
156
  if ((part.time as P)?.end) this.items.finishText(id, kind, text);
133
157
  return;
134
158
  }
135
- if (part.type === "tool") this.tool(id, part);
159
+ if (part.type === "tool") {
160
+ if (this.last.id === str(part.messageID)) this.last.calls.add(id);
161
+ this.tool(id, part);
162
+ }
136
163
  }
137
164
 
138
165
  private delta(p: P): void {
@@ -140,6 +167,11 @@ export class OpenCodeMapper {
140
167
  const seen = this.parts.get(str(p.partID));
141
168
  if (!seen || p.field !== "text") return;
142
169
  const text = str(p.delta);
170
+ if (seen.kind === "assistant" && this.last.text.has(str(p.partID)))
171
+ this.last.text.set(
172
+ str(p.partID),
173
+ this.last.text.get(str(p.partID)) + text
174
+ );
143
175
  seen.shown += text.length;
144
176
  this.items.delta(str(p.partID), seen.kind, text);
145
177
  }
@@ -168,11 +200,33 @@ export class OpenCodeMapper {
168
200
  private idle(): void {
169
201
  if (!this.admitted || !this.items.inTurn) return;
170
202
  this.emit({ type: "usage", totals: { ...this.totals } });
203
+ const text = [...this.last.text.values()].join("\n");
204
+ const ended =
205
+ this.aborted || !this.last.id
206
+ ? undefined
207
+ : turnOutcome({
208
+ finishReason: this.last.finish,
209
+ text,
210
+ toolCalls: this.last.calls.size,
211
+ error: this.error ?? undefined,
212
+ });
213
+ if (ended)
214
+ logTurnEnd({
215
+ driver: "opencode",
216
+ provider: this.last.provider,
217
+ model: this.last.model,
218
+ finishReason: this.last.finish,
219
+ reason: ended.reason,
220
+ text,
221
+ });
171
222
  this.items.endTurn({
172
223
  interrupted: this.aborted,
173
224
  error: this.error ?? undefined,
225
+ finishReason: this.last.finish || undefined,
226
+ ended,
174
227
  });
175
228
  this.error = null;
229
+ this.last.id = "";
176
230
  this.aborted = false;
177
231
  this.admitted = false;
178
232
  for (const id of this.current) this.past.add(id);
@@ -74,11 +74,14 @@ function ensureDir(): void {
74
74
  fs.chmodSync(DIR, 0o700);
75
75
  }
76
76
 
77
- export function writeExtension(): string {
77
+ export function writeExtension(
78
+ name = "agentos-approvals.mjs",
79
+ source = EXTENSION
80
+ ): string {
78
81
  ensureDir();
79
- const file = path.join(DIR, "agentos-approvals.mjs");
80
- if (!fs.existsSync(file) || fs.readFileSync(file, "utf8") !== EXTENSION)
81
- fs.writeFileSync(file, EXTENSION);
82
+ const file = path.join(DIR, name);
83
+ if (!fs.existsSync(file) || fs.readFileSync(file, "utf8") !== source)
84
+ fs.writeFileSync(file, source);
82
85
  return file;
83
86
  }
84
87
 
@@ -2,6 +2,7 @@ import { randomUUID } from "crypto";
2
2
  import type { DriverEvent, UsageTotals } from "../events";
3
3
  import { toolDiff, toolTitle } from "../tools";
4
4
  import { TurnItems, type ToolStart } from "./turn-items";
5
+ import { logTurnEnd, turnOutcome } from "./turn-outcome";
5
6
 
6
7
  type P = Record<string, unknown>;
7
8
  const str = (v: unknown) => (typeof v === "string" ? v : "");
@@ -50,6 +51,9 @@ export class PiMapper {
50
51
  private message = 0;
51
52
  private stopReason = "";
52
53
  private error = "";
54
+ // The last answer's text, its tool calls and who gave it, for the notice
55
+ // when a turn ends without doing anything.
56
+ private last = { text: "", calls: 0, provider: "", model: "", api: "" };
53
57
  totals: UsageTotals = {
54
58
  costUsd: 0,
55
59
  inputTokens: 0,
@@ -71,6 +75,7 @@ export class PiMapper {
71
75
  switch (e.type) {
72
76
  case "agent_start":
73
77
  this.stopReason = this.error = "";
78
+ this.last = { text: "", calls: 0, provider: "", model: "", api: "" };
74
79
  return this.items.startTurn();
75
80
  case "message_start":
76
81
  if ((e.message as P)?.role === "assistant") this.message++;
@@ -136,6 +141,22 @@ export class PiMapper {
136
141
  this.stopReason = str(m.stopReason);
137
142
  if (this.stopReason === "error")
138
143
  this.error = str(m.errorMessage) || "Pi failed";
144
+ const content = Array.isArray(m.content) ? (m.content as P[]) : [];
145
+ this.last = {
146
+ text: content
147
+ .filter((c) => c.type === "text")
148
+ .map((c) => str(c.text))
149
+ .join("\n"),
150
+ calls: content.filter((c) => c.type === "toolCall").length,
151
+ provider: str(m.provider),
152
+ model: str(m.model),
153
+ api: str(m.api),
154
+ };
155
+ // Calls the text-tools extension made out of the reply's markup: the
156
+ // text that streamed still has it.
157
+ const fixed = m.aosRecovered as P | undefined;
158
+ if (fixed)
159
+ this.items.retext(`${this.message}:${fixed.index}`, str(fixed.text));
139
160
  const u = (m.usage ?? {}) as P;
140
161
  const n = (k: string) => Number(u[k]) || 0;
141
162
  this.totals = {
@@ -151,9 +172,26 @@ export class PiMapper {
151
172
  endTurn(): void {
152
173
  if (!this.items.inTurn) return;
153
174
  this.emit({ type: "usage", totals: { ...this.totals } });
175
+ const ended = this.aborted
176
+ ? undefined
177
+ : turnOutcome({
178
+ finishReason: this.stopReason,
179
+ text: this.last.text,
180
+ toolCalls: this.last.calls,
181
+ error: this.error || undefined,
182
+ });
183
+ if (ended)
184
+ logTurnEnd({
185
+ driver: "pi",
186
+ ...this.last,
187
+ finishReason: this.stopReason,
188
+ reason: ended.reason,
189
+ });
154
190
  this.items.endTurn({
155
191
  interrupted: this.aborted,
156
192
  error: this.error || undefined,
193
+ finishReason: this.stopReason || undefined,
194
+ ended,
157
195
  });
158
196
  }
159
197
  }
@@ -0,0 +1,104 @@
1
+ // Local models served without tool-call parsing write their calls as text:
2
+ // <tool_call>{"name":…,"arguments":…}</tool_call> (Hermes, Qwen) or
3
+ // <tool_call><function=bash><parameter=command>…</parameter></function>
4
+ // </tool_call> (Qwen3-Coder). Pi only runs structured calls, so this Pi
5
+ // extension turns those trailing blocks in a finished reply into real tool
6
+ // calls. They are ordinary tool calls from then on: Pi's tool_call event
7
+ // fires for them, so the approvals extension (pi-approvals) asks, or lets
8
+ // them through, exactly as for a structured call. Exported for its test.
9
+ export const TEXT_TOOLS_EXTENSION = String.raw`const OPEN = "<tool_call>";
10
+ const CLOSE = "</tool_call>";
11
+ let count = 0;
12
+ const FENCE = String.fromCharCode(96).repeat(3);
13
+ const inFence = (text, at) => text.slice(0, at).split(FENCE).length % 2 === 0;
14
+ const trimOne = (v) => v.replace(/^\r?\n/, "").replace(/\r?\n$/, "");
15
+
16
+ function parseBody(body, schemas) {
17
+ const b = body.trim();
18
+ if (b.startsWith("{")) {
19
+ let o;
20
+ try {
21
+ o = JSON.parse(b);
22
+ } catch {
23
+ return null;
24
+ }
25
+ let args = o.arguments ?? o.parameters ?? {};
26
+ if (typeof args === "string") {
27
+ try {
28
+ args = JSON.parse(args);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ if (typeof o.name !== "string" || !args || typeof args !== "object" || Array.isArray(args)) return null;
34
+ return { name: o.name, arguments: args };
35
+ }
36
+ const f = /^<function=([^>\s]+)>([\s\S]*?)(?:<\/function>)?$/.exec(b);
37
+ if (!f) return null;
38
+ const props = schemas.get(f[1])?.properties ?? {};
39
+ const args = {};
40
+ for (const m of f[2].matchAll(/<parameter=([^>\s]+)>([\s\S]*?)<\/parameter>/g)) {
41
+ const raw = trimOne(m[2]);
42
+ let value = raw;
43
+ if (props[m[1]]?.type && props[m[1]].type !== "string") {
44
+ try {
45
+ value = JSON.parse(raw);
46
+ } catch {}
47
+ }
48
+ args[m[1]] = value;
49
+ }
50
+ return { name: f[1], arguments: args };
51
+ }
52
+
53
+ // The calls in a tail that is nothing but call blocks, or null.
54
+ function parseTail(tail, schemas) {
55
+ const calls = [];
56
+ let pos = 0;
57
+ for (;;) {
58
+ while (pos < tail.length && /\s/.test(tail[pos])) pos++;
59
+ if (pos >= tail.length) break;
60
+ if (!tail.startsWith(OPEN, pos)) return null;
61
+ const start = pos + OPEN.length;
62
+ const end = tail.indexOf(CLOSE, start);
63
+ const call = parseBody(end < 0 ? tail.slice(start) : tail.slice(start, end), schemas);
64
+ if (!call || !schemas.has(call.name)) return null;
65
+ calls.push(call);
66
+ if (end < 0) break;
67
+ pos = end + CLOSE.length;
68
+ }
69
+ return calls.length ? calls : null;
70
+ }
71
+
72
+ export default function (pi) {
73
+ pi.on("message_end", async (event) => {
74
+ const m = event.message;
75
+ if (m?.role !== "assistant" || m.stopReason !== "stop" || !Array.isArray(m.content)) return;
76
+ if (m.content.some((c) => c.type === "toolCall")) return;
77
+ let at = -1;
78
+ for (let i = m.content.length - 1; i >= 0; i--) {
79
+ if (m.content[i].type === "text") {
80
+ at = i;
81
+ break;
82
+ }
83
+ }
84
+ if (at < 0 || !m.content[at].text.includes(OPEN)) return;
85
+ const text = m.content[at].text;
86
+ const schemas = new Map();
87
+ const active = new Set(pi.getActiveTools());
88
+ for (const t of pi.getAllTools()) if (active.has(t.name)) schemas.set(t.name, t.parameters ?? {});
89
+ for (let from = text.indexOf(OPEN); from >= 0; from = text.indexOf(OPEN, from + 1)) {
90
+ if (inFence(text, from)) continue;
91
+ const calls = parseTail(text.slice(from), schemas);
92
+ if (!calls) continue;
93
+ const rest = text.slice(0, from).trimEnd();
94
+ const content = m.content.slice();
95
+ content[at] = { ...content[at], text: rest };
96
+ if (!rest) content.splice(at, 1);
97
+ for (const c of calls) {
98
+ content.push({ type: "toolCall", id: "aostext_" + ++count + "_" + Math.random().toString(36).slice(2, 8), name: c.name, arguments: c.arguments });
99
+ }
100
+ return { message: { ...m, content, stopReason: "toolUse", aosRecovered: { index: at, text: rest } } };
101
+ }
102
+ });
103
+ }
104
+ `;
@@ -4,6 +4,7 @@ import type { ChatImage, DriverEvent } from "../events";
4
4
  import { InputQueue } from "../queue";
5
5
  import { AGENT_DEFAULT_MODEL } from "../../providers/registry";
6
6
  import { PiAccess, PiDialogs, writeExtension } from "./pi-approvals";
7
+ import { TEXT_TOOLS_EXTENSION } from "./pi-text-tools";
7
8
  import { PiMapper } from "./pi-mapper";
8
9
  import { PiRpc } from "./pi-rpc";
9
10
 
@@ -11,8 +12,8 @@ type P = Record<string, unknown>;
11
12
 
12
13
  const SETTLE_TRIES = 5;
13
14
 
14
- export function piArgs(o: ChatStartOptions, extension: string): string[] {
15
- const args = ["-e", extension];
15
+ export function piArgs(o: ChatStartOptions, ...extensions: string[]): string[] {
16
+ const args = extensions.flatMap((e) => ["-e", e]);
16
17
  // A path, so a conversation started elsewhere never makes Pi ask about it.
17
18
  if (o.resumeId) args.push("--session", o.resumeId);
18
19
  if (o.model && o.model !== AGENT_DEFAULT_MODEL) args.push("--model", o.model);
@@ -79,7 +80,11 @@ export const piDriver: ChatDriver = {
79
80
  // Sent now: goes once the stopped run has ended.
80
81
  const held: { text: string; images?: ChatImage[] }[] = [];
81
82
  const rpc: PiRpc = new PiRpc(
82
- piArgs(options, writeExtension()),
83
+ piArgs(
84
+ options,
85
+ writeExtension(),
86
+ writeExtension("agentos-text-tools.mjs", TEXT_TOOLS_EXTENSION)
87
+ ),
83
88
  {
84
89
  cwd: options.cwd,
85
90
  env: {
@@ -1,5 +1,11 @@
1
1
  import { randomUUID } from "crypto";
2
- import type { ChatItem, DriverEvent, FileDiff, ToolStatus } from "../events";
2
+ import type {
3
+ ChatItem,
4
+ DriverEvent,
5
+ FileDiff,
6
+ ToolStatus,
7
+ TurnEndReason,
8
+ } from "../events";
3
9
  import { clipOutput } from "../tools";
4
10
 
5
11
  type Tool = Extract<ChatItem, { kind: "tool" }>;
@@ -41,6 +47,8 @@ export function readableError(text: string): string {
41
47
  export class TurnItems {
42
48
  private texts = new Map<string, Text>();
43
49
  private tools = new Map<string, Tool>();
50
+ // Texts that finished in this turn, so one can be corrected afterwards.
51
+ private done = new Map<string, Text>();
44
52
  private startedAt = 0;
45
53
  inTurn = false;
46
54
 
@@ -81,15 +89,23 @@ export class TurnItems {
81
89
  this.texts.delete(key);
82
90
  const text = full ?? current?.text ?? "";
83
91
  if (!text.trim()) return;
84
- this.emit({
85
- type: "item",
86
- item: {
87
- id: current?.id ?? randomUUID(),
88
- kind,
89
- text,
90
- createdAt: current?.createdAt ?? Date.now(),
91
- },
92
- });
92
+ const item: Text = {
93
+ id: current?.id ?? randomUUID(),
94
+ kind,
95
+ text,
96
+ createdAt: current?.createdAt ?? Date.now(),
97
+ };
98
+ this.done.set(key, item);
99
+ this.emit({ type: "item", item });
100
+ }
101
+
102
+ // A finished text replaced by `text` (blank hides it): the agent's final
103
+ // message differs from what streamed.
104
+ retext(key: string, text: string): void {
105
+ const item = this.done.get(key);
106
+ if (!item) return;
107
+ this.done.set(key, { ...item, text });
108
+ this.emit({ type: "item", item: { ...item, text } });
93
109
  }
94
110
 
95
111
  tool(id: string, start: ToolStart): void {
@@ -158,7 +174,13 @@ export class TurnItems {
158
174
 
159
175
  // Closes the turn: text still streaming settles, tools still running
160
176
  // were cut off.
161
- endTurn(end: { interrupted?: boolean; error?: string; costUsd?: number }) {
177
+ endTurn(end: {
178
+ interrupted?: boolean;
179
+ error?: string;
180
+ costUsd?: number;
181
+ finishReason?: string;
182
+ ended?: { reason: TurnEndReason; notice: string };
183
+ }) {
162
184
  if (!this.inTurn) return;
163
185
  this.inTurn = false;
164
186
  for (const [key, text] of [...this.texts]) this.finishText(key, text.kind);
@@ -172,9 +194,12 @@ export class TurnItems {
172
194
  durationMs: Date.now() - this.startedAt,
173
195
  costUsd: end.costUsd,
174
196
  interrupted: end.interrupted,
197
+ finishReason: end.finishReason,
198
+ ended: end.interrupted ? undefined : end.ended,
175
199
  createdAt: Date.now(),
176
200
  },
177
201
  });
202
+ this.done.clear();
178
203
  this.emit({ type: "state", state: "idle" });
179
204
  }
180
205
  }
@@ -0,0 +1,108 @@
1
+ import { redact } from "../../orchestrator/untrusted";
2
+ import type { TurnEndReason } from "../events";
3
+
4
+ export interface TurnEnded {
5
+ reason: TurnEndReason;
6
+ notice: string;
7
+ }
8
+
9
+ // Said where a reply stopped short, so the fix is in the notice itself.
10
+ const SERVER_HINT =
11
+ "If you run the model yourself, start its server with tool calling on (llama.cpp: --jinja; vLLM: --enable-auto-tool-choice --tool-call-parser; LM Studio: a model with tool use).";
12
+
13
+ const TAIL = 300;
14
+ const LOG_TAIL = 240;
15
+
16
+ const CLOSERS =
17
+ /\b(let me know|i'?ll (wait|leave|stop|hold)|i will (wait|leave|stop)|if you'?d like|feel free|hope (this|that)|want me to)\b/i;
18
+ const ANNOUNCE =
19
+ /\b(let me|let'?s|i'?ll|i will|i'?m going to|i am going to|going to|i need to|i should|now i|first,? i|next,? i|then i)\s+(\w+)/i;
20
+
21
+ // Whether the end of a reply says the model is about to do something.
22
+ export function announcesAction(text: string): boolean {
23
+ const tail = text.trim().slice(-TAIL);
24
+ if (!tail) return false;
25
+ if (tail.endsWith("?")) return false;
26
+ const last =
27
+ tail
28
+ .split(/(?<=[.!:])\s+|\n+/)
29
+ .filter((s) => s.trim())
30
+ .slice(-2)
31
+ .join(" ") || tail;
32
+ if (CLOSERS.test(last)) return false;
33
+ return ANNOUNCE.test(last) || /:\s*$/.test(tail);
34
+ }
35
+
36
+ // Tool-call markup left in a reply: a call that was written, not made.
37
+ export function hasToolMarkup(text: string): boolean {
38
+ return (
39
+ /<tool_call>|<function=|<\|tool_call\|>/.test(text) ||
40
+ /\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"(arguments|parameters)"\s*:\s*\{[\s\S]*\}\s*$/.test(
41
+ text
42
+ )
43
+ );
44
+ }
45
+
46
+ export function turnOutcome(r: {
47
+ finishReason?: string;
48
+ text: string;
49
+ toolCalls: number;
50
+ error?: string;
51
+ }): TurnEnded | undefined {
52
+ if (r.finishReason === "length")
53
+ return {
54
+ reason: "length",
55
+ notice:
56
+ "The model hit its output limit (finish reason: length) before it finished. Raise maxTokens for this model.",
57
+ };
58
+ if (r.error)
59
+ return {
60
+ reason: "error",
61
+ notice: "The model's server returned an error, shown above.",
62
+ };
63
+ if (r.toolCalls > 0) return undefined;
64
+ if (hasToolMarkup(r.text))
65
+ return {
66
+ reason: "parse_failure",
67
+ notice: `The model wrote a tool call as text that AgentOS couldn't run. ${SERVER_HINT}`,
68
+ };
69
+ if (!r.text.trim())
70
+ return {
71
+ reason: "empty",
72
+ notice: `The model ended its turn with an empty reply. ${SERVER_HINT}`,
73
+ };
74
+ if (announcesAction(r.text))
75
+ return {
76
+ reason: "no_tool_call",
77
+ notice: `The model ended its turn without calling a tool. ${SERVER_HINT}`,
78
+ };
79
+ return undefined;
80
+ }
81
+
82
+ // One line to diagnose a turn that ended early: where, why and how the
83
+ // reply ended. Never the prompt, and the reply's tail is scrubbed and cut.
84
+ export function logTurnEnd(o: {
85
+ driver: string;
86
+ provider?: string;
87
+ model?: string;
88
+ api?: string;
89
+ finishReason?: string;
90
+ reason: TurnEndReason;
91
+ text: string;
92
+ }): void {
93
+ const tail = redact(
94
+ o.text.slice(-LOG_TAIL).replace(/[\u0000-\u001f\u007f]/g, " ")
95
+ );
96
+ console.warn(
97
+ "[chat] turn ended early",
98
+ JSON.stringify({
99
+ driver: o.driver,
100
+ provider: o.provider,
101
+ model: o.model,
102
+ api: o.api,
103
+ finishReason: o.finishReason,
104
+ reason: o.reason,
105
+ tail,
106
+ })
107
+ );
108
+ }
@@ -150,6 +150,9 @@ interface Base {
150
150
  createdAt: number;
151
151
  }
152
152
 
153
+ export type TurnEndReason =
154
+ "no_tool_call" | "length" | "parse_failure" | "empty" | "error";
155
+
153
156
  export type ChatItem =
154
157
  | (Base & {
155
158
  kind: "user";
@@ -205,6 +208,11 @@ export type ChatItem =
205
208
  durationMs?: number;
206
209
  costUsd?: number;
207
210
  interrupted?: boolean;
211
+ // The model's last finish reason, as its agent reported it.
212
+ finishReason?: string;
213
+ // Set when the turn stopped short of finished work: the reader sees
214
+ // why instead of a plain "Done".
215
+ ended?: { reason: TurnEndReason; notice: string };
208
216
  })
209
217
  | (Base & { kind: "command_output"; text: string })
210
218
  // The conversation's MCP servers, as /mcp reports them.
@@ -78,6 +78,17 @@ interface Registry {
78
78
  listeners: Map<string, Set<Listener>>;
79
79
  // What each agent offers per folder: commands, skills, models.
80
80
  caps: Map<string, Capabilities>;
81
+ // Told of every finished turn (lib/analyst), set by the server.
82
+ turnEndHook?: TurnEndHook;
83
+ }
84
+
85
+ export type TurnEndHook = (
86
+ sessionId: string,
87
+ item: Extract<ChatItem, { kind: "turn_end" }>
88
+ ) => void;
89
+
90
+ export function setTurnEndHook(fn: TurnEndHook): void {
91
+ registry.turnEndHook = fn;
81
92
  }
82
93
 
83
94
  // Shared across every module instance in the process (custom server and the
@@ -102,6 +113,8 @@ export function emit(sessionId: string, m: ChatServerMessage): void {
102
113
  (m.type === "item" && m.item.kind === "turn_end")
103
114
  )
104
115
  touchActive(sessionId);
116
+ if (m.type === "item" && m.item.kind === "turn_end")
117
+ registry.turnEndHook?.(sessionId, m.item);
105
118
  registry.listeners.get(sessionId)?.forEach((fn) => fn(m));
106
119
  // Idle, running, waiting and back, and a finished turn (unread): the
107
120
  // sidebar hears at once.
package/lib/chat/store.ts CHANGED
@@ -153,6 +153,25 @@ export function lastItems(
153
153
  return rows.reverse().map((r) => JSON.parse(r.data) as ChatItem);
154
154
  }
155
155
 
156
+ // The items after `afterItemId`, oldest first, at most the latest `limit`;
157
+ // with no item given (or one since deleted), just the latest `limit`.
158
+ export function itemsAfter(
159
+ sessionId: string,
160
+ afterItemId: string | null,
161
+ limit = 400
162
+ ): ChatItem[] {
163
+ flushItems();
164
+ const rows = db
165
+ .prepare(
166
+ `SELECT data FROM chat_items WHERE session_id = ?
167
+ AND seq > COALESCE((SELECT seq FROM chat_items
168
+ WHERE session_id = ? AND item_id = ?), -1)
169
+ ORDER BY seq DESC LIMIT ?`
170
+ )
171
+ .all(sessionId, sessionId, afterItemId, limit) as { data: string }[];
172
+ return rows.reverse().map((r) => JSON.parse(r.data) as ChatItem);
173
+ }
174
+
156
175
  // What the agent has said since the last message to it, as text: where a
157
176
  // chat task's BLOCKED: line stays until someone answers it.
158
177
  export function saidSinceLastMessage(sessionId: string, n = 20): string {
package/lib/chat/turns.ts CHANGED
@@ -107,6 +107,7 @@ export function eventTurnAt(
107
107
  (i) =>
108
108
  i.kind === "plan" ||
109
109
  i.kind === "error" ||
110
+ (i.kind === "turn_end" && !!i.ended) ||
110
111
  isQuestionTool(i) ||
111
112
  isAskTool(i) ||
112
113
  (i.kind === "approval" && i.status === "pending")
@@ -38,11 +38,13 @@ async function ownExtras(session: Session): Promise<RoleExtras> {
38
38
  return (await import("../../orchestrator/worker")).orchestratorExtras(
39
39
  session
40
40
  );
41
- if (!session.task_brief) return {};
41
+ // A role's brief (and an analyst's) is the session's own system prompt.
42
+ const own = session.system_prompt?.trim();
43
+ if (!session.task_brief) return own ? { systemAppend: own } : {};
42
44
  const atTokens =
43
45
  session.task_status === "running" ? refreshSettings().taskTokens : 0;
44
46
  return {
45
- systemAppend: session.task_brief,
47
+ systemAppend: [session.task_brief, own].filter(Boolean).join("\n\n"),
46
48
  ...(atTokens > 0 && { refresh: { atTokens, role: "task" as const } }),
47
49
  };
48
50
  }