@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
@@ -0,0 +1,140 @@
1
+ import { z } from "zod";
2
+
3
+ // What the analyst says the coding agent should do next.
4
+ export const VERDICTS = ["continue", "done", "blocked", "needs_you"] as const;
5
+ export type Verdict = (typeof VERDICTS)[number];
6
+
7
+ export const RECOMMENDATION_STATES = [
8
+ "open",
9
+ "sent",
10
+ "dismissed",
11
+ "superseded",
12
+ "invalid",
13
+ ] as const;
14
+ export type RecommendationState = (typeof RECOMMENDATION_STATES)[number];
15
+
16
+ export const issueSchema = z.object({
17
+ severity: z.enum(["high", "medium", "low"]),
18
+ title: z.string().trim().min(1).max(200),
19
+ detail: z.string().trim().max(2_000).default(""),
20
+ where: z.string().trim().max(300).optional(),
21
+ });
22
+ export type Issue = z.infer<typeof issueSchema>;
23
+
24
+ export const MAX_ISSUES = 8;
25
+ export const MAX_PROMPT = 4_000;
26
+
27
+ // The fenced `agentos-review` block the analyst ends each review with.
28
+ export const reviewSchema = z.object({
29
+ verdict: z.enum(VERDICTS),
30
+ summary: z.string().trim().min(1).max(1_000),
31
+ issues: z.array(issueSchema).max(MAX_ISSUES).default([]),
32
+ prompt: z.string().trim().max(MAX_PROMPT).nullable().optional(),
33
+ });
34
+ export type Review = z.infer<typeof reviewSchema>;
35
+
36
+ // What a batch of the coding agent's turns did, worked out without a model.
37
+ export interface DigestSignals {
38
+ turns: number;
39
+ toolCalls: number;
40
+ failedTools: number;
41
+ // Test, build, typecheck or lint commands, and whether each passed.
42
+ checks: { command: string; passed: boolean }[];
43
+ filesChanged: string[];
44
+ durationMs: number;
45
+ costUsd: number;
46
+ interrupted: boolean;
47
+ errors: number;
48
+ // Turns that ended without a single tool call.
49
+ noToolTurns: number;
50
+ // Fetched web content: a prompt built on it shouldn't be sent unseen.
51
+ readWeb: boolean;
52
+ }
53
+
54
+ export interface AnalystLink {
55
+ id: string;
56
+ coderSessionId: string;
57
+ analystSessionId: string;
58
+ paused: boolean;
59
+ // The coding agent's last turn_end the analyst has answered for.
60
+ reviewedThrough: string | null;
61
+ // A digest sent and not yet answered, the turn_end it runs to, and what
62
+ // its turns did.
63
+ pendingDigestId: string | null;
64
+ pendingThrough: string | null;
65
+ digestSignals: DigestSignals | null;
66
+ createdAt: number;
67
+ endedAt: number | null;
68
+ }
69
+
70
+ export interface Recommendation {
71
+ id: string;
72
+ linkId: string;
73
+ analystItemId: string;
74
+ coderThrough: string | null;
75
+ verdict: Verdict | null;
76
+ summary: string;
77
+ issues: Issue[];
78
+ signals: DigestSignals | null;
79
+ prompt: string | null;
80
+ state: RecommendationState;
81
+ // Why it's invalid.
82
+ error: string | null;
83
+ sentText: string | null;
84
+ sentItemId: string | null;
85
+ edited: boolean;
86
+ createdAt: number;
87
+ decidedAt: number | null;
88
+ }
89
+
90
+ export interface PaneSession {
91
+ id: string;
92
+ name: string;
93
+ agentType: string;
94
+ model: string | null;
95
+ }
96
+
97
+ // A link as the panes and the native app read it.
98
+ export interface AnalystLinkView extends AnalystLink {
99
+ coder: PaneSession;
100
+ analyst: PaneSession;
101
+ // The coding agent's latest finished turn, to tell a card it has moved on.
102
+ coderLastTurn: string | null;
103
+ recommendations: Recommendation[];
104
+ }
105
+
106
+ export class AnalystError extends Error {
107
+ constructor(
108
+ message: string,
109
+ readonly status: 400 | 404 | 409 = 400
110
+ ) {
111
+ super(message);
112
+ }
113
+ }
114
+
115
+ export const createLinkSchema = z
116
+ .object({
117
+ coderSessionId: z.string().min(1).optional(),
118
+ newCoder: z
119
+ .object({
120
+ projectId: z.string().min(1),
121
+ agentType: z.string().min(1),
122
+ model: z.string().trim().min(1).optional(),
123
+ prompt: z.string().trim().max(20_000).optional(),
124
+ useWorktree: z.boolean().optional(),
125
+ })
126
+ .optional(),
127
+ // The analyst's model for this pair, "provider/model"; else its role's.
128
+ analystModel: z.string().trim().min(1).max(200).optional(),
129
+ reviewLast: z.boolean().optional(),
130
+ })
131
+ .refine((b) => !!b.coderSessionId !== !!b.newCoder, {
132
+ message: "Give coderSessionId or newCoder, not both",
133
+ });
134
+ export type CreateLinkInput = z.infer<typeof createLinkSchema>;
135
+
136
+ export const patchLinkSchema = z.object({ paused: z.boolean() });
137
+
138
+ export const sendSchema = z.object({
139
+ text: z.string().trim().min(1).max(20_000).optional(),
140
+ });
@@ -102,7 +102,11 @@ function lineOf(i: ChatItem): string | null {
102
102
  case "compacted":
103
103
  return "[conversation compacted]";
104
104
  case "turn_end":
105
- return i.interrupted ? "[turn stopped]" : null;
105
+ return i.interrupted
106
+ ? "[turn stopped]"
107
+ : i.ended
108
+ ? `[turn ended early: ${i.ended.reason}]`
109
+ : null;
106
110
  default:
107
111
  return null;
108
112
  }
package/lib/bus/index.ts CHANGED
@@ -67,6 +67,9 @@ export interface Peer {
67
67
  waitedBy: string[];
68
68
  // The user's group it sits in, when it is in one.
69
69
  group?: string;
70
+ // The agent role it was started in (lib/models/agent-roles), and its model.
71
+ agentRole?: string;
72
+ model?: string;
70
73
  }
71
74
 
72
75
  // A chat has no pane: its worker is the state, and it can always take a
@@ -118,6 +121,8 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
118
121
  orchestratorOf: orchestratorOf(s),
119
122
  waitingOn: badges.get(s.id)?.on.map((w) => w.name) ?? [],
120
123
  waitedBy: badges.get(s.id)?.by.map((w) => w.name) ?? [],
124
+ ...(s.agent_role && { agentRole: s.agent_role }),
125
+ ...(s.agent_role && s.model && { model: s.model }),
121
126
  ...(s.session_group_id &&
122
127
  groups.has(s.session_group_id) && {
123
128
  group: groups.get(s.session_group_id),
@@ -4,6 +4,7 @@ import { getProject } from "../projects";
4
4
  import { getWorkspace } from "../workspaces";
5
5
  import { previousNames } from "../session-names";
6
6
  import { resolveRef, type Candidate } from "./resolve";
7
+ import { resolveRoleAlias, roleAliasOf } from "./role-alias";
7
8
 
8
9
  export type ReadableSession = Session & { project_name: string | null };
9
10
 
@@ -79,6 +80,17 @@ export function resolveReadable(callerId: string, ref: string): Readable {
79
80
  a.updated_at < b.updated_at ? 1 : -1
80
81
  );
81
82
  const mine = all.filter((s) => inScope(s, scope));
83
+ const alias = roleAliasOf(ref);
84
+ if (alias) {
85
+ const found = resolveRoleAlias(
86
+ alias,
87
+ mine.filter(isLive),
88
+ all.find((s) => s.id === callerId)
89
+ );
90
+ return found.ok
91
+ ? { ok: true, session: found.session }
92
+ : { ok: false, external: found.none, error: found.error };
93
+ }
82
94
  let none = "";
83
95
  for (const group of [mine.filter(isLive), mine.filter((s) => !isLive(s))]) {
84
96
  const r = resolveRef(ref, candidates(group));
@@ -0,0 +1,70 @@
1
+ /**
2
+ * "@reviewer", "@developer", "@analyst", "@supervisor": the live session in
3
+ * that agent role. The caller's crew first, then its project; a supervisor
4
+ * nobody started is the workspace's orchestrator. More than one match
5
+ * refuses and lists them, like any other name.
6
+ */
7
+
8
+ import type { Session } from "../db";
9
+
10
+ type RoleSession = Pick<
11
+ Session,
12
+ | "id"
13
+ | "name"
14
+ | "role"
15
+ | "agent_role"
16
+ | "crew_id"
17
+ | "project_id"
18
+ | "workspace_id"
19
+ >;
20
+ import {
21
+ AGENT_ROLES,
22
+ isAgentRole,
23
+ type AgentRole,
24
+ } from "../models/agent-roles";
25
+
26
+ export type RoleAlias<T extends RoleSession = Session> =
27
+ { ok: true; session: T } | { ok: false; none: boolean; error: string };
28
+
29
+ export const roleAliasOf = (ref: string): AgentRole | null => {
30
+ const m = /^@([a-z]+)$/i.exec(ref.trim());
31
+ const role = m?.[1].toLowerCase();
32
+ return isAgentRole(role) ? role : null;
33
+ };
34
+
35
+ const label = (s: RoleSession) => `${s.name} (id ${s.id.slice(0, 8)})`;
36
+
37
+ export function resolveRoleAlias<T extends RoleSession>(
38
+ role: AgentRole,
39
+ sessions: T[],
40
+ caller: RoleSession | undefined
41
+ ): RoleAlias<T> {
42
+ const others = sessions.filter((s) => s.id !== caller?.id);
43
+ const inRole = others.filter((s) => s.agent_role === role);
44
+ const scopes = [
45
+ caller?.crew_id ? inRole.filter((s) => s.crew_id === caller.crew_id) : [],
46
+ caller?.project_id && caller.project_id !== "uncategorized"
47
+ ? inRole.filter((s) => s.project_id === caller.project_id)
48
+ : [],
49
+ ];
50
+ for (const found of scopes) {
51
+ if (found.length === 1) return { ok: true, session: found[0] };
52
+ if (found.length > 1)
53
+ return {
54
+ ok: false,
55
+ none: false,
56
+ error: `"@${role}" matches ${found.length} sessions: ${found.map(label).join(", ")}. Use the name or the id`,
57
+ };
58
+ }
59
+ if (role === "supervisor" && caller?.workspace_id) {
60
+ const orchestrator = others.find(
61
+ (s) => s.role === "orchestrator" && s.workspace_id === caller.workspace_id
62
+ );
63
+ if (orchestrator) return { ok: true, session: orchestrator };
64
+ }
65
+ return {
66
+ ok: false,
67
+ none: true,
68
+ error: `No ${role} is running in your crew or project. Roles: ${AGENT_ROLES.map((r) => `@${r}`).join(", ")}. Run: aos peers`,
69
+ };
70
+ }
@@ -9,6 +9,7 @@ import { previousNames } from "../session-names";
9
9
  import { sessionWorkspace } from "../schedules/store";
10
10
  import { heldTarget } from "./held-reply";
11
11
  import { resolveRef, type Candidate } from "./resolve";
12
+ import { resolveRoleAlias, roleAliasOf } from "./role-alias";
12
13
 
13
14
  export function liveSessions(): Session[] {
14
15
  return db
@@ -86,6 +87,8 @@ export function tryResolveTarget(
86
87
  | { ok: true; session: Session; note?: string }
87
88
  | { ok: false; none: boolean; error: string } {
88
89
  const sessions = liveSessions();
90
+ const alias = roleAliasOf(ref);
91
+ if (alias) return resolveRoleAlias(alias, sessions, sessionById(callerId));
89
92
  const own = ownOrchestrator(ref, sessions, callerId);
90
93
  if (own) return { ok: true, session: own };
91
94
  const r = resolveRef(ref, candidates(sessions));
@@ -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: {