@pleri/olam-cli 0.1.7

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 (196) hide show
  1. package/dist/__tests__/auth-status.test.d.ts +2 -0
  2. package/dist/__tests__/auth-status.test.d.ts.map +1 -0
  3. package/dist/__tests__/auth-status.test.js +290 -0
  4. package/dist/__tests__/auth-status.test.js.map +1 -0
  5. package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
  6. package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
  7. package/dist/__tests__/auth-upgrade.test.js +161 -0
  8. package/dist/__tests__/auth-upgrade.test.js.map +1 -0
  9. package/dist/__tests__/create-app-urls.test.d.ts +2 -0
  10. package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
  11. package/dist/__tests__/create-app-urls.test.js +102 -0
  12. package/dist/__tests__/create-app-urls.test.js.map +1 -0
  13. package/dist/__tests__/enter.test.d.ts +2 -0
  14. package/dist/__tests__/enter.test.d.ts.map +1 -0
  15. package/dist/__tests__/enter.test.js +90 -0
  16. package/dist/__tests__/enter.test.js.map +1 -0
  17. package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
  18. package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
  19. package/dist/__tests__/host-cp-gh-token.test.js +119 -0
  20. package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
  21. package/dist/__tests__/host-cp.test.d.ts +9 -0
  22. package/dist/__tests__/host-cp.test.d.ts.map +1 -0
  23. package/dist/__tests__/host-cp.test.js +254 -0
  24. package/dist/__tests__/host-cp.test.js.map +1 -0
  25. package/dist/__tests__/keys.test.d.ts +9 -0
  26. package/dist/__tests__/keys.test.d.ts.map +1 -0
  27. package/dist/__tests__/keys.test.js +145 -0
  28. package/dist/__tests__/keys.test.js.map +1 -0
  29. package/dist/__tests__/logs.test.d.ts +9 -0
  30. package/dist/__tests__/logs.test.d.ts.map +1 -0
  31. package/dist/__tests__/logs.test.js +124 -0
  32. package/dist/__tests__/logs.test.js.map +1 -0
  33. package/dist/__tests__/ps.test.d.ts +2 -0
  34. package/dist/__tests__/ps.test.d.ts.map +1 -0
  35. package/dist/__tests__/ps.test.js +172 -0
  36. package/dist/__tests__/ps.test.js.map +1 -0
  37. package/dist/__tests__/status-app-urls.test.d.ts +2 -0
  38. package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
  39. package/dist/__tests__/status-app-urls.test.js +125 -0
  40. package/dist/__tests__/status-app-urls.test.js.map +1 -0
  41. package/dist/__tests__/upgrade.test.d.ts +9 -0
  42. package/dist/__tests__/upgrade.test.d.ts.map +1 -0
  43. package/dist/__tests__/upgrade.test.js +262 -0
  44. package/dist/__tests__/upgrade.test.js.map +1 -0
  45. package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
  46. package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
  47. package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
  48. package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
  49. package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
  50. package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
  51. package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
  52. package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
  53. package/dist/commands/__tests__/refresh.test.d.ts +13 -0
  54. package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
  55. package/dist/commands/__tests__/refresh.test.js +170 -0
  56. package/dist/commands/__tests__/refresh.test.js.map +1 -0
  57. package/dist/commands/auth-status.d.ts +43 -0
  58. package/dist/commands/auth-status.d.ts.map +1 -0
  59. package/dist/commands/auth-status.js +208 -0
  60. package/dist/commands/auth-status.js.map +1 -0
  61. package/dist/commands/auth-upgrade.d.ts +47 -0
  62. package/dist/commands/auth-upgrade.d.ts.map +1 -0
  63. package/dist/commands/auth-upgrade.js +277 -0
  64. package/dist/commands/auth-upgrade.js.map +1 -0
  65. package/dist/commands/auth.d.ts +16 -0
  66. package/dist/commands/auth.d.ts.map +1 -0
  67. package/dist/commands/auth.js +283 -0
  68. package/dist/commands/auth.js.map +1 -0
  69. package/dist/commands/create.d.ts +8 -0
  70. package/dist/commands/create.d.ts.map +1 -0
  71. package/dist/commands/create.js +512 -0
  72. package/dist/commands/create.js.map +1 -0
  73. package/dist/commands/crystallize.d.ts +8 -0
  74. package/dist/commands/crystallize.d.ts.map +1 -0
  75. package/dist/commands/crystallize.js +101 -0
  76. package/dist/commands/crystallize.js.map +1 -0
  77. package/dist/commands/destroy.d.ts +6 -0
  78. package/dist/commands/destroy.d.ts.map +1 -0
  79. package/dist/commands/destroy.js +54 -0
  80. package/dist/commands/destroy.js.map +1 -0
  81. package/dist/commands/dispatch.d.ts +9 -0
  82. package/dist/commands/dispatch.d.ts.map +1 -0
  83. package/dist/commands/dispatch.js +94 -0
  84. package/dist/commands/dispatch.js.map +1 -0
  85. package/dist/commands/enter.d.ts +63 -0
  86. package/dist/commands/enter.d.ts.map +1 -0
  87. package/dist/commands/enter.js +206 -0
  88. package/dist/commands/enter.js.map +1 -0
  89. package/dist/commands/host-cp.d.ts +191 -0
  90. package/dist/commands/host-cp.d.ts.map +1 -0
  91. package/dist/commands/host-cp.js +797 -0
  92. package/dist/commands/host-cp.js.map +1 -0
  93. package/dist/commands/init.d.ts +9 -0
  94. package/dist/commands/init.d.ts.map +1 -0
  95. package/dist/commands/init.js +143 -0
  96. package/dist/commands/init.js.map +1 -0
  97. package/dist/commands/install.d.ts +22 -0
  98. package/dist/commands/install.d.ts.map +1 -0
  99. package/dist/commands/install.js +203 -0
  100. package/dist/commands/install.js.map +1 -0
  101. package/dist/commands/keys.d.ts +26 -0
  102. package/dist/commands/keys.d.ts.map +1 -0
  103. package/dist/commands/keys.js +151 -0
  104. package/dist/commands/keys.js.map +1 -0
  105. package/dist/commands/lanes.d.ts +18 -0
  106. package/dist/commands/lanes.d.ts.map +1 -0
  107. package/dist/commands/lanes.js +122 -0
  108. package/dist/commands/lanes.js.map +1 -0
  109. package/dist/commands/list.d.ts +6 -0
  110. package/dist/commands/list.d.ts.map +1 -0
  111. package/dist/commands/list.js +39 -0
  112. package/dist/commands/list.js.map +1 -0
  113. package/dist/commands/logs.d.ts +38 -0
  114. package/dist/commands/logs.d.ts.map +1 -0
  115. package/dist/commands/logs.js +177 -0
  116. package/dist/commands/logs.js.map +1 -0
  117. package/dist/commands/observe.d.ts +9 -0
  118. package/dist/commands/observe.d.ts.map +1 -0
  119. package/dist/commands/observe.js +34 -0
  120. package/dist/commands/observe.js.map +1 -0
  121. package/dist/commands/policy-check.d.ts +14 -0
  122. package/dist/commands/policy-check.d.ts.map +1 -0
  123. package/dist/commands/policy-check.js +76 -0
  124. package/dist/commands/policy-check.js.map +1 -0
  125. package/dist/commands/pr.d.ts +17 -0
  126. package/dist/commands/pr.d.ts.map +1 -0
  127. package/dist/commands/pr.js +148 -0
  128. package/dist/commands/pr.js.map +1 -0
  129. package/dist/commands/ps.d.ts +25 -0
  130. package/dist/commands/ps.d.ts.map +1 -0
  131. package/dist/commands/ps.js +164 -0
  132. package/dist/commands/ps.js.map +1 -0
  133. package/dist/commands/refresh-helpers.d.ts +25 -0
  134. package/dist/commands/refresh-helpers.d.ts.map +1 -0
  135. package/dist/commands/refresh-helpers.js +56 -0
  136. package/dist/commands/refresh-helpers.js.map +1 -0
  137. package/dist/commands/refresh.d.ts +23 -0
  138. package/dist/commands/refresh.d.ts.map +1 -0
  139. package/dist/commands/refresh.js +237 -0
  140. package/dist/commands/refresh.js.map +1 -0
  141. package/dist/commands/status.d.ts +6 -0
  142. package/dist/commands/status.d.ts.map +1 -0
  143. package/dist/commands/status.js +51 -0
  144. package/dist/commands/status.js.map +1 -0
  145. package/dist/commands/upgrade.d.ts +67 -0
  146. package/dist/commands/upgrade.d.ts.map +1 -0
  147. package/dist/commands/upgrade.js +358 -0
  148. package/dist/commands/upgrade.js.map +1 -0
  149. package/dist/commands/workspace.d.ts +23 -0
  150. package/dist/commands/workspace.d.ts.map +1 -0
  151. package/dist/commands/workspace.js +198 -0
  152. package/dist/commands/workspace.js.map +1 -0
  153. package/dist/commands/world-snapshot.d.ts +18 -0
  154. package/dist/commands/world-snapshot.d.ts.map +1 -0
  155. package/dist/commands/world-snapshot.js +327 -0
  156. package/dist/commands/world-snapshot.js.map +1 -0
  157. package/dist/context.d.ts +26 -0
  158. package/dist/context.d.ts.map +1 -0
  159. package/dist/context.js +51 -0
  160. package/dist/context.js.map +1 -0
  161. package/dist/index.d.ts +9 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +18007 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/mcp-server.js +32236 -0
  166. package/dist/output.d.ts +10 -0
  167. package/dist/output.d.ts.map +1 -0
  168. package/dist/output.js +31 -0
  169. package/dist/output.js.map +1 -0
  170. package/host-cp/compose.yaml +126 -0
  171. package/host-cp/src/auth-secret-hint.mjs +45 -0
  172. package/host-cp/src/auth.mjs +155 -0
  173. package/host-cp/src/compose-worlds-sources.mjs +170 -0
  174. package/host-cp/src/container-secret-fetcher.mjs +163 -0
  175. package/host-cp/src/docker-events.mjs +184 -0
  176. package/host-cp/src/local-worlds-source.mjs +83 -0
  177. package/host-cp/src/plan-orchestrator.mjs +829 -0
  178. package/host-cp/src/plan-progress.mjs +282 -0
  179. package/host-cp/src/pr-cache.mjs +201 -0
  180. package/host-cp/src/pr-merge-poller.mjs +154 -0
  181. package/host-cp/src/process-poller.mjs +250 -0
  182. package/host-cp/src/proxy.mjs +245 -0
  183. package/host-cp/src/pylon-worlds-source.mjs +68 -0
  184. package/host-cp/src/redact.mjs +67 -0
  185. package/host-cp/src/secret-cache.mjs +104 -0
  186. package/host-cp/src/server.mjs +2215 -0
  187. package/host-cp/src/sse-gate.mjs +117 -0
  188. package/host-cp/src/version-status.mjs +209 -0
  189. package/host-cp/src/workspace-catalog.mjs +149 -0
  190. package/host-cp/src/world-names-store.mjs +176 -0
  191. package/host-cp/src/world-pr-state.mjs +97 -0
  192. package/host-cp/src/world-progress.mjs +322 -0
  193. package/host-cp/src/world-tunnel-manager.mjs +288 -0
  194. package/host-cp/src/worlds-db-source.mjs +191 -0
  195. package/host-cp/src/worlds-source.mjs +59 -0
  196. package/package.json +38 -0
@@ -0,0 +1,829 @@
1
+ // plan-orchestrator.mjs — Phase 2: multi-persona conversation coordinator.
2
+ //
3
+ // Architecture:
4
+ // - AgentRegistry holds one pi AgentSession per (conversationId, personaId).
5
+ // - HandoffEngine forks the session tree when the active persona changes.
6
+ // - All persona turns share one session.jsonl per conversation.
7
+ // - SSE sinks are an in-process Set<ServerResponse> per conversationId.
8
+ //
9
+ // Credentials:
10
+ // - Uses the Olam auth-service vault (same as the rest of host-cp).
11
+ // - No ANTHROPIC_API_KEY required; tokens fetched on demand via auth-service.
12
+
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import fs from 'node:fs';
16
+ import { randomUUID } from 'node:crypto';
17
+ import Database from 'better-sqlite3';
18
+ import { SessionManager } from '@mariozechner/pi-coding-agent';
19
+ import { PERSONAS, DEFAULT_PERSONA_ID, getPersona } from './plan/personas.mjs';
20
+ import { AgentRegistry } from './plan/agent-registry.mjs';
21
+ import { HandoffEngine } from './plan/handoff-engine.mjs';
22
+
23
+ // ── Paths ─────────────────────────────────────────────────────────────────────
24
+
25
+ const PLAN_DB_PATH = path.join(os.homedir(), '.olam', 'plan.db');
26
+ const PLAN_DIR = path.join(os.homedir(), '.olam', 'plan');
27
+
28
+ // ── Helpers ───────────────────────────────────────────────────────────────────
29
+
30
+ function initSessionFile(sessionFile, sessionId) {
31
+ const header = {
32
+ type: 'session',
33
+ version: 3,
34
+ id: sessionId,
35
+ timestamp: new Date().toISOString(),
36
+ cwd: os.homedir(),
37
+ };
38
+ fs.writeFileSync(sessionFile, JSON.stringify(header) + '\n');
39
+ }
40
+
41
+ /**
42
+ * Derive a short title from the first user message content.
43
+ * Truncates at a word boundary to at most maxLen characters.
44
+ * @param {string} content
45
+ * @param {number} [maxLen=40]
46
+ * @returns {string}
47
+ */
48
+ export function deriveTitle(content, maxLen = 40) {
49
+ const trimmed = content.trim().replace(/\s+/g, ' ');
50
+ if (!trimmed) return '(empty)';
51
+ if (trimmed.length <= maxLen) return trimmed;
52
+ const cut = trimmed.slice(0, maxLen);
53
+ const lastSpace = cut.lastIndexOf(' ');
54
+ return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut) + '…';
55
+ }
56
+
57
+ // ── PlanOrchestrator ──────────────────────────────────────────────────────────
58
+
59
+ export class PlanOrchestrator {
60
+ #db;
61
+ #authServiceUrl;
62
+ #authServiceSecret;
63
+ #registry;
64
+ #handoffEngine;
65
+
66
+ /** Tracks the active persona per conversationId: Map<conversationId, personaId> */
67
+ #activePersona = new Map();
68
+
69
+ /** @type {Map<string, Set<import('node:http').ServerResponse>>} */
70
+ #sinks = new Map();
71
+
72
+ /**
73
+ * Ring buffer of in-flight SSE events per conversationId.
74
+ * Populated while a turn is active; cleared on turn_complete.
75
+ * Used by drainReplayBuffer to replay missed events on reconnect.
76
+ * @type {Map<string, Array<{event: string, data: object}>>}
77
+ */
78
+ #activeTurns = new Map();
79
+
80
+ /**
81
+ * Mutable current-chunk refs per conversationId.
82
+ * ChunkEmitter updates these; read_sidebar tool reads them.
83
+ * @type {Map<string, { current: string|null }>}
84
+ */
85
+ #currentChunkRefs = new Map();
86
+
87
+ /**
88
+ * @param {{ authServiceUrl: string, authServiceSecret: string }} opts
89
+ */
90
+ constructor({ authServiceUrl, authServiceSecret }) {
91
+ this.#authServiceUrl = authServiceUrl;
92
+ this.#authServiceSecret = authServiceSecret;
93
+
94
+ this.#registry = new AgentRegistry({ authServiceUrl, authServiceSecret });
95
+ this.#handoffEngine = new HandoffEngine(this.#registry);
96
+
97
+ fs.mkdirSync(path.dirname(PLAN_DB_PATH), { recursive: true });
98
+ this.#db = new Database(PLAN_DB_PATH);
99
+ this.#db.exec(`
100
+ CREATE TABLE IF NOT EXISTS plan_conversations (
101
+ id TEXT PRIMARY KEY,
102
+ title TEXT,
103
+ persona TEXT NOT NULL DEFAULT 'brainstorm',
104
+ created_at INTEGER NOT NULL,
105
+ last_turn_at INTEGER
106
+ );
107
+ CREATE TABLE IF NOT EXISTS plan_turns (
108
+ id TEXT PRIMARY KEY,
109
+ conversation_id TEXT NOT NULL REFERENCES plan_conversations(id),
110
+ role TEXT NOT NULL,
111
+ content TEXT NOT NULL DEFAULT '',
112
+ persona TEXT,
113
+ from_persona TEXT,
114
+ to_persona TEXT,
115
+ mode TEXT,
116
+ fork_node_id TEXT,
117
+ created_at INTEGER NOT NULL
118
+ );
119
+ CREATE INDEX IF NOT EXISTS plan_turns_conv_idx
120
+ ON plan_turns(conversation_id, created_at);
121
+
122
+ -- Phase 4B: lookout agent registry per conversation
123
+ CREATE TABLE IF NOT EXISTS plan_lookout_agents (
124
+ conversation_id TEXT NOT NULL,
125
+ persona_id TEXT NOT NULL,
126
+ muted INTEGER NOT NULL DEFAULT 0,
127
+ mode TEXT NOT NULL DEFAULT 'observe',
128
+ created_at INTEGER NOT NULL,
129
+ PRIMARY KEY (conversation_id, persona_id)
130
+ );
131
+
132
+ -- Phase 4B: sidebar signals from lookout agents
133
+ CREATE TABLE IF NOT EXISTS plan_sidebar_signals (
134
+ id TEXT PRIMARY KEY,
135
+ conversation_id TEXT NOT NULL,
136
+ agent_id TEXT NOT NULL,
137
+ urgency TEXT NOT NULL DEFAULT 'p2',
138
+ reason TEXT NOT NULL DEFAULT '',
139
+ content TEXT NOT NULL DEFAULT '',
140
+ chunk_id TEXT NOT NULL,
141
+ created_at INTEGER NOT NULL,
142
+ status TEXT NOT NULL DEFAULT 'active',
143
+ tension_subject TEXT,
144
+ parent_signal_id TEXT
145
+ );
146
+ CREATE INDEX IF NOT EXISTS plan_sidebar_conv_idx
147
+ ON plan_sidebar_signals(conversation_id, created_at);
148
+ CREATE INDEX IF NOT EXISTS plan_sidebar_chunk_idx
149
+ ON plan_sidebar_signals(chunk_id);
150
+ `);
151
+ }
152
+
153
+ // ── Auth-service credential fetching ──────────────────────────────────────
154
+
155
+ /**
156
+ * Fetch a live Claude API token from the vault.
157
+ * @returns {Promise<string>}
158
+ */
159
+ async #fetchToken() {
160
+ return this.#registry.fetchToken('claude');
161
+ }
162
+
163
+ /**
164
+ * Lightweight check — returns true if vault has an active credential.
165
+ * @returns {Promise<boolean>}
166
+ */
167
+ async hasCredential() {
168
+ try {
169
+ await this.#fetchToken();
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ // ── Conversation management ───────────────────────────────────────────────
177
+
178
+ /**
179
+ * @param {{ title?: string }} [opts]
180
+ * @returns {{ id: string, title: string|null, persona: string, created_at: number }}
181
+ */
182
+ createConversation({ title } = {}) {
183
+ const id = randomUUID();
184
+ const created_at = Date.now();
185
+
186
+ const sessionDir = path.join(PLAN_DIR, id);
187
+ fs.mkdirSync(sessionDir, { recursive: true });
188
+ initSessionFile(path.join(sessionDir, 'session.jsonl'), id);
189
+
190
+ this.#db
191
+ .prepare(
192
+ `INSERT INTO plan_conversations (id, title, persona, created_at)
193
+ VALUES (?, ?, ?, ?)`,
194
+ )
195
+ .run(id, title ?? null, DEFAULT_PERSONA_ID, created_at);
196
+
197
+ this.#activePersona.set(id, DEFAULT_PERSONA_ID);
198
+
199
+ return { id, title: title ?? null, persona: DEFAULT_PERSONA_ID, created_at };
200
+ }
201
+
202
+ /** @returns {Array<{id, title, created_at, last_turn_at, persona}>} */
203
+ listConversations() {
204
+ return this.#db
205
+ .prepare(
206
+ `SELECT id, title, created_at, last_turn_at, persona
207
+ FROM plan_conversations
208
+ ORDER BY created_at DESC`,
209
+ )
210
+ .all();
211
+ }
212
+
213
+ /**
214
+ * @param {string} id
215
+ * @returns {{ id, title, persona, created_at, last_turn_at, tree } | null}
216
+ */
217
+ getConversation(id) {
218
+ const row = this.#db
219
+ .prepare(
220
+ `SELECT id, title, persona, created_at, last_turn_at
221
+ FROM plan_conversations WHERE id = ?`,
222
+ )
223
+ .get(id);
224
+
225
+ if (!row) return null;
226
+
227
+ const sessionFile = path.join(PLAN_DIR, id, 'session.jsonl');
228
+ let tree = [];
229
+ try {
230
+ const mgr = SessionManager.open(sessionFile, path.join(PLAN_DIR, id));
231
+ tree = mgr.getTree();
232
+ } catch {
233
+ // Session file missing or corrupt — return empty tree.
234
+ }
235
+
236
+ return { ...row, tree };
237
+ }
238
+
239
+ // ── Active persona management ─────────────────────────────────────────────
240
+
241
+ /**
242
+ * @param {string} conversationId
243
+ * @returns {string} Active persona ID.
244
+ */
245
+ getActivePersona(conversationId) {
246
+ if (this.#activePersona.has(conversationId)) {
247
+ return this.#activePersona.get(conversationId);
248
+ }
249
+ const row = this.#db
250
+ .prepare(`SELECT persona FROM plan_conversations WHERE id = ?`)
251
+ .get(conversationId);
252
+ const personaId = row?.persona ?? DEFAULT_PERSONA_ID;
253
+ this.#activePersona.set(conversationId, personaId);
254
+ return personaId;
255
+ }
256
+
257
+ /**
258
+ * Set the active default persona for a conversation (does NOT trigger a handoff).
259
+ * @param {string} conversationId
260
+ * @param {string} personaId
261
+ */
262
+ setActivePersona(conversationId, personaId) {
263
+ this.#activePersona.set(conversationId, personaId);
264
+ this.#db
265
+ .prepare(`UPDATE plan_conversations SET persona = ? WHERE id = ?`)
266
+ .run(personaId, conversationId);
267
+ }
268
+
269
+ // ── SSE broadcast ─────────────────────────────────────────────────────────
270
+
271
+ #broadcast(conversationId, eventName, data) {
272
+ // Buffer event while a turn is active for reconnect replay.
273
+ const buf = this.#activeTurns.get(conversationId);
274
+ if (buf) {
275
+ buf.push({ event: eventName, data });
276
+ }
277
+
278
+ const sinks = this.#sinks.get(conversationId);
279
+ if (!sinks || sinks.size === 0) return;
280
+ const chunk = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
281
+ for (const res of sinks) {
282
+ try { res.write(chunk); } catch { /* client disconnected */ }
283
+ }
284
+
285
+ // Clear buffer after turn_complete so the next turn starts fresh.
286
+ if (eventName === 'turn_complete') {
287
+ this.#activeTurns.delete(conversationId);
288
+ }
289
+ }
290
+
291
+ // ── Lookout agent management ──────────────────────────────────────────────
292
+
293
+ /**
294
+ * Invite a persona as a lookout for a conversation.
295
+ * @param {string} conversationId
296
+ * @param {string} personaId
297
+ * @returns {{ persona_id: string, state: string, muted: boolean, mode: string }}
298
+ */
299
+ inviteLookout(conversationId, personaId) {
300
+ const now = Date.now();
301
+ this.#db
302
+ .prepare(`INSERT OR IGNORE INTO plan_lookout_agents (conversation_id, persona_id, muted, mode, created_at) VALUES (?, ?, 0, 'observe', ?)`)
303
+ .run(conversationId, personaId, now);
304
+ const agent = { persona_id: personaId, state: 'listening', muted: false, mode: 'observe' };
305
+ this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: 'listening' });
306
+ return agent;
307
+ }
308
+
309
+ /**
310
+ * Update muted status (or mode) for a lookout agent.
311
+ * @param {string} conversationId
312
+ * @param {string} personaId
313
+ * @param {{ muted?: boolean, mode?: string }} updates
314
+ * @returns {{ persona_id: string, state: string, muted: boolean, mode: string } | null}
315
+ */
316
+ updateLookout(conversationId, personaId, { muted, mode } = {}) {
317
+ const row = this.#db
318
+ .prepare(`SELECT * FROM plan_lookout_agents WHERE conversation_id = ? AND persona_id = ?`)
319
+ .get(conversationId, personaId);
320
+ if (!row) return null;
321
+
322
+ const newMuted = muted !== undefined ? (muted ? 1 : 0) : row.muted;
323
+ const newMode = mode ?? row.mode;
324
+ this.#db
325
+ .prepare(`UPDATE plan_lookout_agents SET muted = ?, mode = ? WHERE conversation_id = ? AND persona_id = ?`)
326
+ .run(newMuted, newMode, conversationId, personaId);
327
+
328
+ const newState = newMuted ? 'idle' : 'listening';
329
+ this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: newState });
330
+ return { persona_id: personaId, state: newState, muted: !!newMuted, mode: newMode };
331
+ }
332
+
333
+ /**
334
+ * Remove a lookout agent.
335
+ * @param {string} conversationId
336
+ * @param {string} personaId
337
+ */
338
+ uninviteLookout(conversationId, personaId) {
339
+ this.#db
340
+ .prepare(`DELETE FROM plan_lookout_agents WHERE conversation_id = ? AND persona_id = ?`)
341
+ .run(conversationId, personaId);
342
+ }
343
+
344
+ /**
345
+ * List active lookout agents for a conversation.
346
+ * @param {string} conversationId
347
+ * @returns {Array<{ persona_id: string, state: string, muted: boolean, mode: string }>}
348
+ */
349
+ listLookoutAgents(conversationId) {
350
+ const rows = this.#db
351
+ .prepare(`SELECT persona_id, muted, mode FROM plan_lookout_agents WHERE conversation_id = ?`)
352
+ .all(conversationId);
353
+ return rows.map((r) => ({
354
+ persona_id: r.persona_id,
355
+ state: r.muted ? 'idle' : 'listening',
356
+ muted: !!r.muted,
357
+ mode: r.mode,
358
+ }));
359
+ }
360
+
361
+ // ── Sidebar signal management ─────────────────────────────────────────────
362
+
363
+ /**
364
+ * Dismiss a sidebar signal.
365
+ * @param {string} conversationId
366
+ * @param {string} signalId
367
+ * @returns {boolean}
368
+ */
369
+ dismissSignal(conversationId, signalId) {
370
+ const info = this.#db
371
+ .prepare(`UPDATE plan_sidebar_signals SET status = 'dismissed' WHERE id = ? AND conversation_id = ?`)
372
+ .run(signalId, conversationId);
373
+ return info.changes > 0;
374
+ }
375
+
376
+ /**
377
+ * Mark a sidebar signal as used (for next turn context).
378
+ * @param {string} conversationId
379
+ * @param {string} signalId
380
+ * @returns {boolean}
381
+ */
382
+ useSignal(conversationId, signalId) {
383
+ const info = this.#db
384
+ .prepare(`UPDATE plan_sidebar_signals SET status = 'used' WHERE id = ? AND conversation_id = ?`)
385
+ .run(signalId, conversationId);
386
+ return info.changes > 0;
387
+ }
388
+
389
+ /**
390
+ * List sidebar signals for a conversation (optionally filtered by chunk_id).
391
+ * @param {string} conversationId
392
+ * @param {string} [chunkId]
393
+ * @returns {Array<object>}
394
+ */
395
+ listSignals(conversationId, chunkId) {
396
+ if (chunkId) {
397
+ return this.#db
398
+ .prepare(`SELECT * FROM plan_sidebar_signals WHERE conversation_id = ? AND chunk_id = ? ORDER BY created_at ASC`)
399
+ .all(conversationId, chunkId);
400
+ }
401
+ return this.#db
402
+ .prepare(`SELECT * FROM plan_sidebar_signals WHERE conversation_id = ? ORDER BY created_at ASC`)
403
+ .all(conversationId);
404
+ }
405
+
406
+ // ── Lookout analysis ──────────────────────────────────────────────────────
407
+
408
+ /**
409
+ * Persona-specific heuristics for lookout analysis.
410
+ * Returns { shouldComment: boolean, urgency, content, reason, tension_subject? }
411
+ * or null if no comment warranted.
412
+ *
413
+ * @param {string} personaId
414
+ * @param {string} content — chunk content to analyze
415
+ * @returns {{ urgency: string, content: string, reason: string, tension_subject?: string } | null}
416
+ */
417
+ #analyzeChunkHeuristic(personaId, content) {
418
+ const lower = content.toLowerCase();
419
+
420
+ if (personaId === 'scout') {
421
+ // Scout: flag unsubstantiated claims and factual assertions
422
+ const claimPatterns = [
423
+ /\b(research shows|studies (show|indicate|suggest)|data (shows|indicates|suggests))\b/i,
424
+ /\b\d+(\.\d+)?\s*%\b/,
425
+ /\b(always|never|all|every|none|no one)\b/i,
426
+ /\b(proven|definitive|certain|guaranteed|undeniable)\b/i,
427
+ /\b(industry standard|best practice|widely accepted)\b/i,
428
+ ];
429
+ const matched = claimPatterns.find((p) => p.test(content));
430
+ if (matched) {
431
+ return {
432
+ urgency: 'p2',
433
+ reason: 'Factual claim without cited source',
434
+ content: 'This response contains claims that should be verified with evidence. What data or sources back this up?',
435
+ };
436
+ }
437
+ // Scout spark: look for unexplored data angles
438
+ if (lower.includes('option') || lower.includes('approach') || lower.includes('strategy')) {
439
+ if (Math.random() < 0.3) {
440
+ return {
441
+ urgency: 'spark',
442
+ reason: 'Potential evidence gap',
443
+ content: '_What metrics or signals would tell us which option is actually better here?_',
444
+ };
445
+ }
446
+ }
447
+ }
448
+
449
+ if (personaId === 'pm') {
450
+ // PM: flag scope ambiguity and missing requirements
451
+ const scopePatterns = [
452
+ /\b(could|might|maybe|perhaps|possibly|potentially)\b/i,
453
+ /\b(later|eventually|someday|future)\b/i,
454
+ /\b(depends on|unclear|tbd|to be determined)\b/i,
455
+ ];
456
+ const matched = scopePatterns.find((p) => p.test(content));
457
+ if (matched) {
458
+ return {
459
+ urgency: 'p1',
460
+ reason: 'Scope ambiguity detected',
461
+ content: 'Scope boundary needs clarification. What specifically is in vs. out for this iteration?',
462
+ };
463
+ }
464
+ // PM: flag missing success criteria
465
+ if ((lower.includes('implement') || lower.includes('build') || lower.includes('create')) && !lower.includes('success') && !lower.includes('metric') && !lower.includes('goal')) {
466
+ if (Math.random() < 0.4) {
467
+ return {
468
+ urgency: 'p2',
469
+ reason: 'Missing acceptance criteria',
470
+ content: 'What does done look like here? Define the measurable success criteria before building.',
471
+ };
472
+ }
473
+ }
474
+ }
475
+
476
+ if (personaId === 'brainstorm') {
477
+ // Brainstorm: flag premature convergence on a single option
478
+ const convergencePatterns = [
479
+ /\b(the (best|right|correct|only) (way|approach|solution|option))\b/i,
480
+ /\b(we should|we must|we need to|the answer is)\b/i,
481
+ /\b(obviously|clearly|simply|just)\b/i,
482
+ ];
483
+ const matched = convergencePatterns.find((p) => p.test(content));
484
+ if (matched) {
485
+ return {
486
+ urgency: 'spark',
487
+ reason: 'Early convergence on one path',
488
+ content: '_Before narrowing: what\'s the alternative that explicitly rejects this approach? What would it look like?_',
489
+ };
490
+ }
491
+ }
492
+
493
+ return null;
494
+ }
495
+
496
+ /**
497
+ * Run lookout analysis for all active lookout agents after a turn completes.
498
+ * Emits sidebar_entry SSE events for any signals generated.
499
+ *
500
+ * @param {string} conversationId
501
+ * @param {string} chunkId — the turn ID used as chunk reference
502
+ * @param {string} chunkContent — the assistant's response text
503
+ * @param {string} chunkPersona — which persona produced the chunk
504
+ */
505
+ async #runLookoutAnalysis(conversationId, chunkId, chunkContent, chunkPersona) {
506
+ const lookouts = this.#db
507
+ .prepare(`SELECT persona_id, muted FROM plan_lookout_agents WHERE conversation_id = ? AND muted = 0`)
508
+ .all(conversationId);
509
+
510
+ for (const lookout of lookouts) {
511
+ const { persona_id: personaId } = lookout;
512
+
513
+ // Skip if this is the persona that produced the chunk
514
+ if (personaId === chunkPersona) continue;
515
+
516
+ // Emit thinking state
517
+ this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: 'thinking' });
518
+
519
+ // Small async gap to let the SSE event reach the client before analysis
520
+ await new Promise((resolve) => setTimeout(resolve, 300 + Math.random() * 700));
521
+
522
+ try {
523
+ const analysis = this.#analyzeChunkHeuristic(personaId, chunkContent);
524
+
525
+ if (analysis) {
526
+ const signalId = randomUUID();
527
+ const now = Date.now();
528
+ this.#db
529
+ .prepare(
530
+ `INSERT INTO plan_sidebar_signals (id, conversation_id, agent_id, urgency, reason, content, chunk_id, created_at, status, tension_subject)
531
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)`,
532
+ )
533
+ .run(signalId, conversationId, personaId, analysis.urgency, analysis.reason, analysis.content, chunkId, now, analysis.tension_subject ?? null);
534
+
535
+ const signal = {
536
+ id: signalId,
537
+ agent_id: personaId,
538
+ urgency: analysis.urgency,
539
+ reason: analysis.reason,
540
+ content: analysis.content,
541
+ chunk_id: chunkId,
542
+ created_at: now,
543
+ status: 'active',
544
+ tension_subject: analysis.tension_subject ?? null,
545
+ parent_signal_id: null,
546
+ };
547
+
548
+ this.#broadcast(
549
+ conversationId,
550
+ analysis.urgency === 'p0' ? 'interrupt' : 'sidebar_entry',
551
+ { signal },
552
+ );
553
+ }
554
+ } catch (err) {
555
+ console.error(`[plan] lookout analysis error ${conversationId}/${personaId}:`, err.message);
556
+ }
557
+
558
+ // Return to listening state
559
+ this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: 'listening' });
560
+ }
561
+ }
562
+
563
+ // ── Persona subscription setup ────────────────────────────────────────────
564
+
565
+ /**
566
+ * Wire pi event listeners for a session so tokens + turn_complete events are
567
+ * forwarded to SSE clients.
568
+ *
569
+ * @param {string} conversationId
570
+ * @param {string} personaId
571
+ * @param {import('@mariozechner/pi-coding-agent').AgentSession} session
572
+ */
573
+ #wireSessionEvents(conversationId, personaId, session) {
574
+ session.subscribe((event) => {
575
+ if (event.type === 'message_update') {
576
+ const ae = event.assistantMessageEvent;
577
+ if (ae.type === 'text_delta') {
578
+ this.#broadcast(conversationId, 'token', { delta: ae.delta, persona: personaId });
579
+ }
580
+ } else if (event.type === 'agent_end') {
581
+ const msgs = event.messages;
582
+ const last = msgs[msgs.length - 1];
583
+
584
+ let persistedText = '';
585
+ let turnId = last?.id ?? randomUUID();
586
+
587
+ // Persist the assistant turn so history loads correctly.
588
+ if (last) {
589
+ const text = (last.content ?? [])
590
+ .filter((c) => c.type === 'text')
591
+ .map((c) => c.text ?? '')
592
+ .join('');
593
+ if (text) {
594
+ persistedText = text;
595
+ const now = Date.now();
596
+ this.#db
597
+ .prepare(
598
+ `INSERT OR IGNORE INTO plan_turns
599
+ (id, conversation_id, role, content, persona, created_at)
600
+ VALUES (?, ?, 'assistant', ?, ?, ?)`,
601
+ )
602
+ .run(turnId, conversationId, text, personaId, now);
603
+ }
604
+ }
605
+
606
+ this.#broadcast(conversationId, 'turn_complete', {
607
+ turnId,
608
+ persona: personaId,
609
+ finishReason: last?.stopReason ?? 'end_turn',
610
+ });
611
+ this.#db
612
+ .prepare(`UPDATE plan_conversations SET last_turn_at = ? WHERE id = ?`)
613
+ .run(Date.now(), conversationId);
614
+
615
+ // Trigger lookout analysis asynchronously — does not block the turn.
616
+ if (persistedText) {
617
+ this.#runLookoutAnalysis(conversationId, turnId, persistedText, personaId)
618
+ .catch((err) => console.error('[plan] lookout run error:', err.message));
619
+ }
620
+ }
621
+ });
622
+ }
623
+
624
+ // ── Public API ────────────────────────────────────────────────────────────
625
+
626
+ /**
627
+ * Submit a user turn to the active (or overridden) persona.
628
+ * Returns immediately; tokens stream over SSE.
629
+ *
630
+ * @param {{
631
+ * conversationId: string,
632
+ * content: string,
633
+ * personaOverride?: string,
634
+ * }} params
635
+ * @returns {Promise<{ turnId: string, persona: string }>}
636
+ */
637
+ async submitTurn({ conversationId, content, personaOverride }) {
638
+ const row = this.#db
639
+ .prepare(`SELECT id, title FROM plan_conversations WHERE id = ?`)
640
+ .get(conversationId);
641
+
642
+ if (!row) {
643
+ const err = new Error('conversation not found');
644
+ err.code = 'NOT_FOUND';
645
+ throw err;
646
+ }
647
+
648
+ const now = Date.now();
649
+
650
+ // Open (or reset) the replay buffer for this turn.
651
+ this.#activeTurns.set(conversationId, []);
652
+
653
+ // Set title from first user message if still null.
654
+ if (row.title === null) {
655
+ this.#db
656
+ .prepare(`UPDATE plan_conversations SET title = ? WHERE id = ?`)
657
+ .run(deriveTitle(content), conversationId);
658
+ }
659
+
660
+ // Persist the user turn for history replay.
661
+ this.#db
662
+ .prepare(
663
+ `INSERT INTO plan_turns (id, conversation_id, role, content, created_at)
664
+ VALUES (?, ?, 'user', ?, ?)`,
665
+ )
666
+ .run(randomUUID(), conversationId, content, now);
667
+
668
+ const personaId = personaOverride ?? this.getActivePersona(conversationId);
669
+ const onStubCall = (event) => {
670
+ this.#broadcast(conversationId, 'tool_stub_call', { persona: personaId, ...event });
671
+ };
672
+
673
+ const { session, authStorage } = await this.#registry.getAgent(conversationId, personaId, { onStubCall });
674
+
675
+ // Wire events on first use (idempotent because pi de-duplicates subscribers).
676
+ this.#wireSessionEvents(conversationId, personaId, session);
677
+
678
+ // Refresh credential before each turn.
679
+ const token = await this.#fetchToken();
680
+ authStorage.setRuntimeApiKey('anthropic', token);
681
+
682
+ const turnId = randomUUID();
683
+
684
+ session.prompt(content).catch((err) => {
685
+ console.error(`[plan] prompt error ${conversationId}/${personaId}:`, err.message);
686
+ this.#broadcast(conversationId, 'error', {
687
+ message: err.message,
688
+ code: err.code ?? 'PROMPT_ERROR',
689
+ });
690
+ });
691
+
692
+ return { turnId, persona: personaId };
693
+ }
694
+
695
+ /**
696
+ * Execute a handoff, switching the default active persona.
697
+ *
698
+ * @param {{
699
+ * conversationId: string,
700
+ * toPersona: string,
701
+ * mode?: 'full' | 'distilled' | 'quoted',
702
+ * selectedTurnIds?: string[],
703
+ * }} params
704
+ * @returns {Promise<{ handoffId: string, forkNodeId: string | null, seededTurnCount: number }>}
705
+ */
706
+ async handoff({ conversationId, toPersona, mode = 'full', selectedTurnIds = [] }) {
707
+ const row = this.#db
708
+ .prepare(`SELECT id FROM plan_conversations WHERE id = ?`)
709
+ .get(conversationId);
710
+ if (!row) {
711
+ const err = new Error('conversation not found');
712
+ err.code = 'NOT_FOUND';
713
+ throw err;
714
+ }
715
+
716
+ const fromPersona = this.getActivePersona(conversationId);
717
+ const onStubCall = (event) => {
718
+ this.#broadcast(conversationId, 'tool_stub_call', { persona: toPersona, ...event });
719
+ };
720
+
721
+ const result = await this.#handoffEngine.handoff({
722
+ conversationId,
723
+ fromPersona,
724
+ toPersona,
725
+ mode,
726
+ selectedTurnIds,
727
+ fetchToken: () => this.#fetchToken(),
728
+ onStubCall,
729
+ });
730
+
731
+ // Update the active persona for this conversation.
732
+ this.setActivePersona(conversationId, toPersona);
733
+
734
+ // Persist handoff marker so history replay can reconstruct it.
735
+ this.#db
736
+ .prepare(
737
+ `INSERT OR IGNORE INTO plan_turns
738
+ (id, conversation_id, role, content, from_persona, to_persona, mode, fork_node_id, created_at)
739
+ VALUES (?, ?, 'handoff', '', ?, ?, ?, ?, ?)`,
740
+ )
741
+ .run(result.handoffId, conversationId, fromPersona, toPersona, mode, result.forkNodeId ?? null, Date.now());
742
+
743
+ // Broadcast the handoff event to SSE clients.
744
+ this.#broadcast(conversationId, 'handoff', {
745
+ handoffId: result.handoffId,
746
+ fromPersona,
747
+ toPersona,
748
+ mode,
749
+ forkNodeId: result.forkNodeId,
750
+ });
751
+
752
+ // Wire events for the new persona's session.
753
+ try {
754
+ const { session } = await this.#registry.getAgent(conversationId, toPersona, { onStubCall });
755
+ this.#wireSessionEvents(conversationId, toPersona, session);
756
+ } catch {
757
+ // Best-effort — events will be wired on first turn if this fails.
758
+ }
759
+
760
+ return result;
761
+ }
762
+
763
+ /**
764
+ * Replay buffered in-flight SSE events to a reconnecting client.
765
+ * Call this before addEventSink so the client gets events it missed.
766
+ * No-op if no turn is active.
767
+ *
768
+ * @param {string} conversationId
769
+ * @param {import('node:http').ServerResponse} res
770
+ */
771
+ drainReplayBuffer(conversationId, res) {
772
+ const buf = this.#activeTurns.get(conversationId);
773
+ if (!buf || buf.length === 0) return;
774
+ for (const { event, data } of buf) {
775
+ try {
776
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
777
+ } catch { /* client closed before drain completed */ }
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Register an SSE sink for a conversation. Returns a cleanup function.
783
+ * @param {string} conversationId
784
+ * @param {import('node:http').ServerResponse} res
785
+ * @returns {() => void}
786
+ */
787
+ addEventSink(conversationId, res) {
788
+ if (!this.#sinks.has(conversationId)) {
789
+ this.#sinks.set(conversationId, new Set());
790
+ }
791
+ this.#sinks.get(conversationId).add(res);
792
+ return () => {
793
+ const s = this.#sinks.get(conversationId);
794
+ if (s) s.delete(res);
795
+ };
796
+ }
797
+
798
+ /**
799
+ * Return the ordered turn list for a conversation (for history replay).
800
+ * Each turn is one of:
801
+ * { role:'user'|'assistant', content, persona?, created_at }
802
+ * { role:'handoff', from_persona, to_persona, mode, fork_node_id, created_at }
803
+ * @param {string} conversationId
804
+ * @returns {Array<object>}
805
+ */
806
+ getTurns(conversationId) {
807
+ return this.#db
808
+ .prepare(
809
+ `SELECT id, role, content, persona, from_persona, to_persona, mode, fork_node_id, created_at
810
+ FROM plan_turns
811
+ WHERE conversation_id = ?
812
+ ORDER BY created_at ASC`,
813
+ )
814
+ .all(conversationId);
815
+ }
816
+
817
+ /** Expose persona list for the /api/plan/personas endpoint. */
818
+ listPersonas() {
819
+ return PERSONAS.map((p) => ({
820
+ id: p.id,
821
+ displayName: p.displayName,
822
+ model: p.model,
823
+ toolNames: p.toolNames,
824
+ systemPromptPreview: p.systemPrompt.length > 120
825
+ ? p.systemPrompt.slice(0, 117) + '...'
826
+ : p.systemPrompt,
827
+ }));
828
+ }
829
+ }