pipane 0.1.0

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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/bin/pipane.js +22 -0
  4. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +1 -0
  5. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +1 -0
  6. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +1 -0
  7. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +1 -0
  8. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +1 -0
  9. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +1 -0
  10. package/dist/client/assets/index-CQP8RzjO.js +45 -0
  11. package/dist/client/assets/index-CVr_JVrA.js +131 -0
  12. package/dist/client/assets/index-DQxrqu4K.js +4412 -0
  13. package/dist/client/assets/index-DuJn-Nwv.js +3 -0
  14. package/dist/client/assets/index-Pj8zaBYJ.js +1 -0
  15. package/dist/client/assets/index-yL1A-vgM.css +1 -0
  16. package/dist/client/assets/pdf.worker.min-Cpi8b8z3.mjs +28 -0
  17. package/dist/client/favicon.png +0 -0
  18. package/dist/client/index.html +118 -0
  19. package/dist/server/server/attached-session.js +209 -0
  20. package/dist/server/server/load-trace-store.js +48 -0
  21. package/dist/server/server/local-settings.js +376 -0
  22. package/dist/server/server/pi-launch.js +10 -0
  23. package/dist/server/server/pi-runtime.js +32 -0
  24. package/dist/server/server/process-pool.js +254 -0
  25. package/dist/server/server/rest-api.js +289 -0
  26. package/dist/server/server/server.js +355 -0
  27. package/dist/server/server/session-cwd.js +33 -0
  28. package/dist/server/server/session-index.js +231 -0
  29. package/dist/server/server/session-jsonl.js +260 -0
  30. package/dist/server/server/session-lifecycle.js +155 -0
  31. package/dist/server/server/ws-handler.js +808 -0
  32. package/dist/server/shared/jsonl-sync.js +141 -0
  33. package/extensions/canvas.ts +57 -0
  34. package/package.json +82 -0
  35. package/patches/@mariozechner+pi-web-ui+0.55.3.patch +3279 -0
@@ -0,0 +1,231 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { getAgentDir, parseSessionEntries } from "@mariozechner/pi-coding-agent";
4
+ const CACHE_FORMAT_VERSION = 1;
5
+ const DEFAULT_EXTRACTOR_VERSION = "2";
6
+ export class SessionIndex {
7
+ agentDir;
8
+ extractorVersion;
9
+ cacheFilePath;
10
+ cwdDisplayFormatter;
11
+ inMemoryCache;
12
+ constructor(opts) {
13
+ this.agentDir = opts?.agentDir ?? getAgentDir();
14
+ this.extractorVersion = opts?.extractorVersion ?? DEFAULT_EXTRACTOR_VERSION;
15
+ this.cacheFilePath = path.join(this.agentDir, "cache", "pipane-session-index-v1.json");
16
+ this.cwdDisplayFormatter = opts?.cwdDisplayFormatter;
17
+ }
18
+ async listSessions() {
19
+ const files = this.listSessionFiles();
20
+ const existing = this.getCache();
21
+ const canReuse = existing?.extractorVersion === this.extractorVersion;
22
+ const previousEntries = canReuse ? existing.entries : {};
23
+ const nextEntries = {};
24
+ const sessions = [];
25
+ let mutated = !canReuse;
26
+ for (const sessionPath of files) {
27
+ let stat;
28
+ try {
29
+ stat = statSync(sessionPath);
30
+ }
31
+ catch {
32
+ mutated = true;
33
+ continue;
34
+ }
35
+ const cached = previousEntries[sessionPath];
36
+ if (cached && cached.fileMtimeMs === stat.mtimeMs && cached.fileSize === stat.size) {
37
+ nextEntries[sessionPath] = cached;
38
+ sessions.push(cached.meta);
39
+ continue;
40
+ }
41
+ const meta = this.extractSessionMeta(sessionPath, stat.mtimeMs);
42
+ if (!meta) {
43
+ mutated = true;
44
+ continue;
45
+ }
46
+ nextEntries[sessionPath] = {
47
+ fileMtimeMs: stat.mtimeMs,
48
+ fileSize: stat.size,
49
+ meta,
50
+ };
51
+ sessions.push(meta);
52
+ mutated = true;
53
+ }
54
+ if (!mutated) {
55
+ const prevKeys = Object.keys(previousEntries);
56
+ if (prevKeys.length !== Object.keys(nextEntries).length) {
57
+ mutated = true;
58
+ }
59
+ else {
60
+ for (const key of prevKeys) {
61
+ if (!nextEntries[key]) {
62
+ mutated = true;
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
69
+ if (mutated) {
70
+ try {
71
+ this.writeCache({
72
+ cacheFormatVersion: CACHE_FORMAT_VERSION,
73
+ extractorVersion: this.extractorVersion,
74
+ generatedAt: new Date().toISOString(),
75
+ entries: nextEntries,
76
+ });
77
+ }
78
+ catch {
79
+ // Cache writes are best-effort. Listing sessions must still succeed
80
+ // on read-only agent dirs or transient filesystem errors.
81
+ }
82
+ }
83
+ return sessions;
84
+ }
85
+ async invalidateAll() {
86
+ this.inMemoryCache = null;
87
+ try {
88
+ unlinkSync(this.cacheFilePath);
89
+ }
90
+ catch {
91
+ // ignore
92
+ }
93
+ }
94
+ listSessionFiles() {
95
+ const sessionsDir = path.join(this.agentDir, "sessions");
96
+ if (!existsSync(sessionsDir))
97
+ return [];
98
+ const files = [];
99
+ const stack = [sessionsDir];
100
+ while (stack.length > 0) {
101
+ const current = stack.pop();
102
+ let entries;
103
+ try {
104
+ entries = readdirSync(current, { withFileTypes: true });
105
+ }
106
+ catch {
107
+ continue;
108
+ }
109
+ for (const entry of entries) {
110
+ const full = path.join(current, entry.name);
111
+ if (entry.isDirectory())
112
+ stack.push(full);
113
+ else if (entry.isFile() && full.endsWith(".jsonl"))
114
+ files.push(full);
115
+ }
116
+ }
117
+ return files;
118
+ }
119
+ extractSessionMeta(sessionPath, statMtimeMs) {
120
+ try {
121
+ const content = readFileSync(sessionPath, "utf8");
122
+ const entries = parseSessionEntries(content);
123
+ if (entries.length === 0)
124
+ return null;
125
+ const header = entries[0];
126
+ if (header?.type !== "session" || typeof header.id !== "string")
127
+ return null;
128
+ let name;
129
+ let messageCount = 0;
130
+ let firstMessage = "";
131
+ let lastActivityTime;
132
+ let lastUserPromptTimeMs = 0;
133
+ for (const entry of entries) {
134
+ if (entry?.type === "session_info" && typeof entry.name === "string") {
135
+ const trimmed = entry.name.trim();
136
+ if (trimmed)
137
+ name = trimmed;
138
+ }
139
+ if (entry?.type !== "message")
140
+ continue;
141
+ messageCount++;
142
+ const msg = entry.message;
143
+ if (!msg || typeof msg.role !== "string" || !Object.prototype.hasOwnProperty.call(msg, "content"))
144
+ continue;
145
+ if (msg.role !== "user" && msg.role !== "assistant")
146
+ continue;
147
+ const messageTs = typeof msg.timestamp === "number" ? msg.timestamp : undefined;
148
+ const entryTs = typeof entry.timestamp === "string" ? new Date(entry.timestamp).getTime() : NaN;
149
+ const ts = Number.isFinite(messageTs) ? messageTs : (!Number.isNaN(entryTs) ? entryTs : undefined);
150
+ if (typeof ts === "number") {
151
+ lastActivityTime = Math.max(lastActivityTime ?? 0, ts);
152
+ }
153
+ if (msg.role === "user") {
154
+ if (typeof ts === "number") {
155
+ lastUserPromptTimeMs = Math.max(lastUserPromptTimeMs, ts);
156
+ }
157
+ if (!firstMessage) {
158
+ const text = this.extractTextContent(msg.content);
159
+ if (text)
160
+ firstMessage = text;
161
+ }
162
+ }
163
+ }
164
+ const createdMs = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
165
+ const created = !Number.isNaN(createdMs) ? new Date(createdMs) : new Date(statMtimeMs);
166
+ const modified = (() => {
167
+ if (typeof lastActivityTime === "number" && lastActivityTime > 0)
168
+ return new Date(lastActivityTime);
169
+ if (!Number.isNaN(createdMs))
170
+ return new Date(createdMs);
171
+ return new Date(statMtimeMs);
172
+ })();
173
+ const cwd = typeof header.cwd === "string" ? header.cwd : "";
174
+ return {
175
+ id: header.id,
176
+ path: sessionPath,
177
+ cwd,
178
+ cwdDisplay: cwd && this.cwdDisplayFormatter ? this.cwdDisplayFormatter(cwd) : cwd,
179
+ name,
180
+ created: created.toISOString(),
181
+ modified: modified.toISOString(),
182
+ lastUserPromptTime: lastUserPromptTimeMs > 0 ? new Date(lastUserPromptTimeMs).toISOString() : undefined,
183
+ messageCount,
184
+ firstMessage: firstMessage || "(no messages)",
185
+ };
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
191
+ extractTextContent(content) {
192
+ if (typeof content === "string")
193
+ return content;
194
+ if (!Array.isArray(content))
195
+ return "";
196
+ return content
197
+ .filter((c) => c?.type === "text")
198
+ .map((c) => String(c.text ?? ""))
199
+ .join(" ")
200
+ .trim();
201
+ }
202
+ getCache() {
203
+ if (this.inMemoryCache !== undefined)
204
+ return this.inMemoryCache;
205
+ this.inMemoryCache = this.readCacheFromDisk();
206
+ return this.inMemoryCache;
207
+ }
208
+ readCacheFromDisk() {
209
+ try {
210
+ if (!existsSync(this.cacheFilePath))
211
+ return null;
212
+ const parsed = JSON.parse(readFileSync(this.cacheFilePath, "utf8"));
213
+ if (parsed?.cacheFormatVersion !== CACHE_FORMAT_VERSION)
214
+ return null;
215
+ if (!parsed || typeof parsed !== "object" || typeof parsed.entries !== "object")
216
+ return null;
217
+ return parsed;
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ }
223
+ writeCache(cache) {
224
+ const dir = path.dirname(this.cacheFilePath);
225
+ mkdirSync(dir, { recursive: true });
226
+ const tmp = `${this.cacheFilePath}.tmp`;
227
+ writeFileSync(tmp, JSON.stringify(cache), "utf8");
228
+ renameSync(tmp, this.cacheFilePath);
229
+ this.inMemoryCache = cache;
230
+ }
231
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Server-side session state manager that produces a canonical JSON representation.
3
+ *
4
+ * Replaces the old AttachedSession. Instead of maintaining complex state fields
5
+ * (messages, streamMessage, pendingToolCalls, partialToolResults), this class
6
+ * maintains a single JSON string that represents the complete session state.
7
+ *
8
+ * The JSON is a serialized SessionState object that the client can directly
9
+ * use for rendering — no client-side state management needed.
10
+ *
11
+ * Changes are delivered to clients via the hash-verified diff protocol
12
+ * from jsonl-sync.ts.
13
+ */
14
+ import { existsSync, readFileSync, statSync } from "node:fs";
15
+ import { parseSessionEntries, buildSessionContext, } from "@mariozechner/pi-coding-agent";
16
+ import { createHash } from "node:crypto";
17
+ import { computeSyncOp } from "../shared/jsonl-sync.js";
18
+ /** Synchronous SHA-256 hash (server-only, uses node:crypto). */
19
+ function computeHashSync(data) {
20
+ return createHash("sha256").update(data, "utf8").digest("hex");
21
+ }
22
+ // ── SessionJsonl ───────────────────────────────────────────────────────────
23
+ export class SessionJsonl {
24
+ // ── Internal state (used to build the JSON) ────────────────────────────
25
+ _messages;
26
+ _streamMessage = null;
27
+ _pendingToolCalls = [];
28
+ _partialToolResults = {};
29
+ _model;
30
+ _thinkingLevel;
31
+ _steeringQueue = [];
32
+ _error;
33
+ // ── Serialized state + hash ────────────────────────────────────────────
34
+ _json;
35
+ _hash;
36
+ /** Monotonically increasing version for change detection */
37
+ _version = 1;
38
+ constructor(init) {
39
+ this._messages = init.messages;
40
+ this._model = init.model;
41
+ this._thinkingLevel = init.thinkingLevel;
42
+ // Build initial JSON
43
+ this._json = this.buildJson();
44
+ this._hash = computeHashSync(this._json);
45
+ }
46
+ get version() { return this._version; }
47
+ get json() { return this._json; }
48
+ get hash() { return this._hash; }
49
+ // Expose for ws-handler steering queue management
50
+ get steeringQueue() { return this._steeringQueue; }
51
+ set steeringQueue(q) {
52
+ this._steeringQueue = q;
53
+ this.rebuildJson();
54
+ }
55
+ // Expose model for ws-handler
56
+ get model() { return this._model; }
57
+ // Expose messages for post-detach snapshot
58
+ get messages() { return this._messages; }
59
+ /**
60
+ * Replace the internal messages array (e.g. after auto-compaction rewrites
61
+ * the session). Rebuilds the JSON and bumps the version.
62
+ */
63
+ replaceMessages(messages) {
64
+ this._messages = messages;
65
+ this._streamMessage = null;
66
+ this._pendingToolCalls = [];
67
+ this._partialToolResults = {};
68
+ this._version++;
69
+ this.rebuildJson();
70
+ }
71
+ /**
72
+ * Apply a streaming event from the pi process.
73
+ * Returns true if state changed.
74
+ */
75
+ applyEvent(event) {
76
+ switch (event.type) {
77
+ case "agent_start":
78
+ this._error = undefined;
79
+ this._streamMessage = null;
80
+ this._version++;
81
+ this.rebuildJson();
82
+ return true;
83
+ case "message_start":
84
+ this._streamMessage = event.message;
85
+ this._version++;
86
+ this.rebuildJson();
87
+ return true;
88
+ case "message_update":
89
+ this._streamMessage = event.message;
90
+ this._version++;
91
+ this.rebuildJson();
92
+ return true;
93
+ case "message_end": {
94
+ this._streamMessage = null;
95
+ const msg = event.message;
96
+ if (msg) {
97
+ this._messages = [...this._messages, msg];
98
+ }
99
+ this._version++;
100
+ this.rebuildJson();
101
+ return true;
102
+ }
103
+ case "turn_end": {
104
+ if (event.message?.role === "assistant" && event.message?.errorMessage) {
105
+ this._error = event.message.errorMessage;
106
+ this._version++;
107
+ this.rebuildJson();
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ case "tool_execution_start": {
113
+ const toolCallId = event.toolCallId;
114
+ if (toolCallId && !this._pendingToolCalls.includes(toolCallId)) {
115
+ this._pendingToolCalls = [...this._pendingToolCalls, toolCallId];
116
+ this._version++;
117
+ this.rebuildJson();
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ case "tool_execution_update": {
123
+ const toolCallId = event.toolCallId;
124
+ const partialResult = event.partialResult;
125
+ if (toolCallId && partialResult) {
126
+ this._partialToolResults = { ...this._partialToolResults, [toolCallId]: partialResult };
127
+ this._version++;
128
+ this.rebuildJson();
129
+ return true;
130
+ }
131
+ return false;
132
+ }
133
+ case "tool_execution_end": {
134
+ const toolCallId = event.toolCallId;
135
+ if (toolCallId) {
136
+ this._pendingToolCalls = this._pendingToolCalls.filter(id => id !== toolCallId);
137
+ if (toolCallId in this._partialToolResults) {
138
+ const { [toolCallId]: _, ...rest } = this._partialToolResults;
139
+ this._partialToolResults = rest;
140
+ }
141
+ this._version++;
142
+ this.rebuildJson();
143
+ return true;
144
+ }
145
+ return false;
146
+ }
147
+ default:
148
+ return false;
149
+ }
150
+ }
151
+ /**
152
+ * Compute a SyncOp to send to a client.
153
+ *
154
+ * @param clientJson - The client's current JSON string (empty if first sync)
155
+ * @param clientHash - The client's current hash (empty if first sync)
156
+ * @param clientVersion - The client's last known version
157
+ * @returns SyncOp to send, or null if nothing changed
158
+ */
159
+ computeSyncOp(clientJson, clientHash, clientVersion) {
160
+ if (this._version === clientVersion && clientHash === this._hash)
161
+ return null;
162
+ return computeSyncOp(clientJson, this._json, clientHash, this._hash);
163
+ }
164
+ /**
165
+ * Get the full session state (for reading, not for the wire protocol).
166
+ */
167
+ toState() {
168
+ return this.buildState();
169
+ }
170
+ // ── Private ────────────────────────────────────────────────────────────
171
+ /**
172
+ * Build the flat session state. Merges streamMessage and partialToolResults
173
+ * into the messages array so the client has a single thing to render.
174
+ */
175
+ buildState() {
176
+ const messages = [...this._messages];
177
+ // Inject partial tool results as synthetic toolResult messages
178
+ // so the client's tool renderers can show in-progress output
179
+ for (const [id, partialResult] of Object.entries(this._partialToolResults)) {
180
+ messages.push({
181
+ role: "toolResult",
182
+ toolCallId: id,
183
+ content: partialResult.content ?? [],
184
+ isError: false,
185
+ details: partialResult.details,
186
+ timestamp: Date.now(),
187
+ });
188
+ }
189
+ // Append the in-flight stream message at the end
190
+ if (this._streamMessage) {
191
+ messages.push(this._streamMessage);
192
+ }
193
+ return {
194
+ messages,
195
+ isStreaming: true,
196
+ pendingToolCalls: this._pendingToolCalls,
197
+ model: this._model,
198
+ thinkingLevel: this._thinkingLevel,
199
+ steeringQueue: this._steeringQueue,
200
+ error: this._error,
201
+ };
202
+ }
203
+ buildJson() {
204
+ return JSON.stringify(this.buildState());
205
+ }
206
+ rebuildJson() {
207
+ const oldJson = this._json;
208
+ this._json = this.buildJson();
209
+ if (this._json !== oldJson) {
210
+ this._hash = computeHashSync(this._json);
211
+ }
212
+ }
213
+ }
214
+ // ── Disk reader ────────────────────────────────────────────────────────────
215
+ /**
216
+ * Read a session from disk and return a serialized SessionState JSON + hash.
217
+ */
218
+ export function readSessionFromDisk(sessionPath) {
219
+ let messages = [];
220
+ let model = null;
221
+ let thinkingLevel = "off";
222
+ try {
223
+ if (existsSync(sessionPath)) {
224
+ const content = readFileSync(sessionPath, "utf8");
225
+ const entries = parseSessionEntries(content);
226
+ const context = buildSessionContext(entries);
227
+ messages = context.messages ?? [];
228
+ model = context.model ?? null;
229
+ thinkingLevel = context.thinkingLevel ?? "off";
230
+ }
231
+ }
232
+ catch (err) {
233
+ console.error(`[session] Failed to read session ${sessionPath}:`, err);
234
+ }
235
+ const state = {
236
+ messages,
237
+ isStreaming: false,
238
+ pendingToolCalls: [],
239
+ model,
240
+ thinkingLevel,
241
+ steeringQueue: [],
242
+ };
243
+ const json = JSON.stringify(state);
244
+ const hash = computeHashSync(json);
245
+ return { json, hash, state };
246
+ }
247
+ /**
248
+ * Get the file size of a session file. Returns 0 if the file doesn't exist.
249
+ */
250
+ export function getSessionFileSize(sessionPath) {
251
+ try {
252
+ if (existsSync(sessionPath)) {
253
+ return statSync(sessionPath).size;
254
+ }
255
+ }
256
+ catch {
257
+ // ignore
258
+ }
259
+ return 0;
260
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Session lifecycle state machine.
3
+ *
4
+ * Single source of truth for session→process mappings, session status,
5
+ * and steering queues. Enforces legal state transitions and emits typed events.
6
+ *
7
+ * States per session:
8
+ * (absent) → session has never been tracked
9
+ * "running" → a pi process is attached and executing a turn
10
+ * "done" → pi process was attached at some point and has since detached
11
+ *
12
+ * Transitions:
13
+ * attach(sessionPath, proc) → sets "running"
14
+ * detach(sessionPath) → sets "done", clears proc mapping
15
+ * crash(sessionPath) → sets "done", clears proc mapping (for unexpected exits)
16
+ */
17
+ export class SessionLifecycle {
18
+ /** session path → attached process */
19
+ attached = new Map();
20
+ /** session path → "running" | "done" (persists for server lifetime) */
21
+ statuses = new Map();
22
+ /** session path → queued steering messages */
23
+ steeringQueues = new Map();
24
+ listeners = new Set();
25
+ // ── Event subscription ───────────────────────────────────────────────
26
+ subscribe(fn) {
27
+ this.listeners.add(fn);
28
+ return () => this.listeners.delete(fn);
29
+ }
30
+ emit(event) {
31
+ for (const fn of this.listeners)
32
+ fn(event);
33
+ }
34
+ // ── State queries ────────────────────────────────────────────────────
35
+ getAttachedProcess(sessionPath) {
36
+ return this.attached.get(sessionPath);
37
+ }
38
+ getAllStatuses() {
39
+ const result = {};
40
+ for (const [k, v] of this.statuses)
41
+ result[k] = v;
42
+ return result;
43
+ }
44
+ getAttachedSessionForProcess(proc) {
45
+ for (const [sessionPath, p] of this.attached) {
46
+ if (p === proc)
47
+ return sessionPath;
48
+ }
49
+ return undefined;
50
+ }
51
+ get attachedCount() {
52
+ return this.attached.size;
53
+ }
54
+ // ── Transitions ──────────────────────────────────────────────────────
55
+ /**
56
+ * Attach a process to a session. Sets status to "running".
57
+ * If the session is already attached, returns the existing process (idempotent).
58
+ */
59
+ attach(sessionPath, proc) {
60
+ const existing = this.attached.get(sessionPath);
61
+ if (existing) {
62
+ return existing;
63
+ }
64
+ this.attached.set(sessionPath, proc);
65
+ this.statuses.set(sessionPath, "running");
66
+ this.emit({ type: "status_change", sessionPath, status: "running" });
67
+ this.emit({ type: "session_attached", sessionPath, procId: proc.id });
68
+ return proc;
69
+ }
70
+ /**
71
+ * Detach a process from a session (normal completion). Sets status to "done".
72
+ * Returns the process that was detached, or undefined if not attached.
73
+ */
74
+ detach(sessionPath) {
75
+ const proc = this.attached.get(sessionPath);
76
+ if (!proc) {
77
+ console.warn(`[lifecycle] detach called for non-attached session: ${sessionPath}`);
78
+ return undefined;
79
+ }
80
+ this.attached.delete(sessionPath);
81
+ this.statuses.set(sessionPath, "done");
82
+ // Clear steering queue on detach
83
+ this.steeringQueues.delete(sessionPath);
84
+ this.emit({ type: "steering_queue_update", sessionPath, queue: [] });
85
+ this.emit({ type: "status_change", sessionPath, status: "done" });
86
+ this.emit({ type: "session_detached", sessionPath, procId: proc.id });
87
+ return proc;
88
+ }
89
+ /**
90
+ * Handle a process crash while attached to a session.
91
+ * Same as detach but named differently for clarity in call sites.
92
+ */
93
+ crash(sessionPath) {
94
+ return this.detach(sessionPath);
95
+ }
96
+ // ── Steering queue management ────────────────────────────────────────
97
+ getAllSteeringQueues() {
98
+ const result = {};
99
+ for (const [k, v] of this.steeringQueues) {
100
+ if (v.length > 0)
101
+ result[k] = [...v];
102
+ }
103
+ return result;
104
+ }
105
+ enqueueSteering(sessionPath, message) {
106
+ let queue = this.steeringQueues.get(sessionPath);
107
+ if (!queue) {
108
+ queue = [];
109
+ this.steeringQueues.set(sessionPath, queue);
110
+ }
111
+ queue.push(message);
112
+ this.emit({ type: "steering_queue_update", sessionPath, queue: [...queue] });
113
+ }
114
+ /**
115
+ * Dequeue a steering message by matching its text.
116
+ * Used when the agent confirms it received a user message.
117
+ */
118
+ dequeueSteering(sessionPath, text) {
119
+ const queue = this.steeringQueues.get(sessionPath);
120
+ if (!queue)
121
+ return false;
122
+ const idx = queue.indexOf(text);
123
+ if (idx === -1)
124
+ return false;
125
+ queue.splice(idx, 1);
126
+ if (queue.length === 0) {
127
+ this.steeringQueues.delete(sessionPath);
128
+ }
129
+ this.emit({ type: "steering_queue_update", sessionPath, queue: [...(this.steeringQueues.get(sessionPath) ?? [])] });
130
+ return true;
131
+ }
132
+ /**
133
+ * Remove a steering message by index.
134
+ */
135
+ removeSteeringByIndex(sessionPath, index) {
136
+ const queue = this.steeringQueues.get(sessionPath);
137
+ if (!queue || index < 0 || index >= queue.length)
138
+ return false;
139
+ queue.splice(index, 1);
140
+ if (queue.length === 0) {
141
+ this.steeringQueues.delete(sessionPath);
142
+ }
143
+ this.emit({ type: "steering_queue_update", sessionPath, queue: [...(this.steeringQueues.get(sessionPath) ?? [])] });
144
+ return true;
145
+ }
146
+ /**
147
+ * Clear all steering messages for a session.
148
+ */
149
+ clearSteering(sessionPath) {
150
+ if (!this.steeringQueues.has(sessionPath))
151
+ return;
152
+ this.steeringQueues.delete(sessionPath);
153
+ this.emit({ type: "steering_queue_update", sessionPath, queue: [] });
154
+ }
155
+ }