@snoai/memory 1.7.1 → 2.0.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.
- package/README.md +1 -1
- package/dist/agent-state-osRgfRdR.js +928 -0
- package/dist/agent-state-view.d.ts +1 -0
- package/dist/agent-state-view.js +185 -0
- package/dist/{atomic-memory-extraction-CWMMWF2C.js → atomic-memory-extraction-OusdO3Ig.js} +4 -4
- package/dist/client.js +1 -1
- package/dist/coding-skin-D_2RbNDw.js +342 -0
- package/dist/coding-skin.d.ts +87 -1
- package/dist/coding-skin.js +3 -2
- package/dist/{config-D6GJyMKz.js → config-VPVf190N.js} +1 -1
- package/dist/diagnostic-encoder.js +1 -1
- package/dist/index.js +5 -5
- package/dist/internal/config/settings.js +1 -1
- package/dist/internal/engine/bindings/memory-forget-tool.js +1 -1
- package/dist/internal/engine/bindings/memory-list-tool.js +1 -1
- package/dist/internal/engine/bindings/memory-recall-tool.js +1 -1
- package/dist/internal/engine/bindings/memory-reflection-resolve-tool.js +1 -1
- package/dist/internal/engine/bindings/memory-stats-tool.js +1 -1
- package/dist/internal/engine/bindings/memory-store-tool.js +3 -3
- package/dist/internal/engine/bindings/memory-tool-access.js +1 -1
- package/dist/internal/engine/bindings/memory-tool-results.js +1 -1
- package/dist/internal/engine/bindings/memory-tool-schemas.js +1 -1
- package/dist/internal/engine/bindings/memory-update-tool.js +2 -2
- package/dist/internal/engine/bindings/sno-station-mem-ambient-learning-hook.js +2 -2
- package/dist/internal/engine/bindings/sno-station-mem-auto-recall-hook.js +1 -1
- package/dist/internal/engine/bindings/sno-station-mem-insight-distill-factory.js +2 -2
- package/dist/internal/engine/bindings/sno-station-mem-message-transcript.js +2 -2
- package/dist/internal/engine/extraction/atomic-memory-extraction.js +1 -1
- package/dist/internal/engine/extraction/memory-temporality-classifier.js +1 -1
- package/dist/internal/engine/observability/observable-memory-store.js +1 -1
- package/dist/internal/engine/observability/runtime-diagnostics.js +1 -1
- package/dist/internal/engine/reflection/reflection-command-hooks.js +1 -1
- package/dist/internal/engine/reflection/reflection-embedded-generator.js +1 -1
- package/dist/internal/engine/reflection/reflection-injection-hooks.js +1 -1
- package/dist/internal/engine/reflection/reflection-lifecycle-hooks.js +1 -1
- package/dist/internal/engine/reflection/strategy-hook-runner.js +2 -2
- package/dist/internal/model/llm-client.js +1 -1
- package/dist/internal/store/maintenance.js +3 -3
- package/dist/internal/store/store.js +1 -1
- package/dist/{llm-client-BtbX4af4.js → llm-client-DhBvu-U_.js} +1 -1
- package/dist/{log-site-catalog.generated-Dtiih6m2.js → log-site-catalog.generated-3zYckrtz.js} +2 -2
- package/dist/log-site-catalog.json +1 -1
- package/dist/memdump.js +1 -1
- package/dist/{memory-runtime-B9fG-sNo.js → memory-runtime-CKIBXN_r.js} +4 -4
- package/dist/{memory-temporality-classifier-Bm0EiCN8.js → memory-temporality-classifier-DTRhzXmG.js} +1 -1
- package/dist/{memory-tool-dependencies-BxVJ-bHs.js → memory-tool-dependencies-BQHE4kMD.js} +1 -1
- package/dist/{profile-section-writer-DSX6R3GF.js → profile-section-writer-QiB0MMtt.js} +2 -2
- package/dist/{reflection-command-hooks-Dyckr_Ma.js → reflection-command-hooks-DBpqpF7A.js} +3 -3
- package/dist/{reflection-embedded-generator-i8KHqMpr.js → reflection-embedded-generator-DNFMQNsv.js} +1 -1
- package/dist/{rem-batch-executor-BSWXFOLH.js → rem-batch-executor-BHItPFZ8.js} +1 -1
- package/dist/{rem-trigger-BaJa5FS0.js → rem-trigger-BN6GCcnE.js} +29 -7
- package/dist/{server-DPd3Xef2.js → server-BvEOkYis.js} +4 -4
- package/dist/sidecar/main.js +2 -2
- package/dist/{sno-station-mem-runtime-dependencies-DITNvfkR.js → sno-station-mem-runtime-dependencies-Fq6yRstU.js} +5 -5
- package/dist/{store-YX1_c1NH.js → store-cu7Qq2OW.js} +1 -1
- package/dist/{strategy-hook-runner-CwqZ1orf.js → strategy-hook-runner-CvdsVcwR.js} +1 -1
- package/package.json +1 -1
- package/settings.default.json +1 -1
- package/dist/coding-skin-BnFryVac.js +0 -265
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { appendFile, mkdir, open, readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { createReadStream } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
// config/session-activity.ts
|
|
10
|
+
/** A gap between two records of at most this long is working time; a longer one ends the run. */
|
|
11
|
+
const ACTIVITY_GAP_MS = 9e5;
|
|
12
|
+
/** A run counts in runs_over_12h once, in the send where it reaches this length, ended or not. */
|
|
13
|
+
const ACTIVITY_LONG_RUN_MS = 432e5;
|
|
14
|
+
/**
|
|
15
|
+
* Text markers of messages the person did not write, seen in real transcripts on the owner's
|
|
16
|
+
* machine (Claude Code 2.1.247 and Codex 0.160, 2026-08 to 2026-10). Heuristics, matched on text.
|
|
17
|
+
*/
|
|
18
|
+
/** Reach's ring and mailbox doorbell, typed into the agent's terminal (apps/reach/lib/reach-ring line 426); recorded as a human prompt by Claude Code and Codex. */
|
|
19
|
+
const REACH_RING_MARKER = "typed by the mail transport, not by the owner";
|
|
20
|
+
/** A heartbeat tick line, "<UTC time> [label] tick=N ..." (apps/heartbeat/bin/heartbeat line 507); Claude Code delivers it inside a Monitor <task-notification>. */
|
|
21
|
+
const HEARTBEAT_TICK = /\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ \[[^\]\n]+\] tick=\d+/;
|
|
22
|
+
/** The first prompt Reach gives a seat it starts in a terminal (apps/reach/lib/reach-spawn line 19). */
|
|
23
|
+
const REACH_SEAT_START_MARKER = "No work is assigned by this startup message";
|
|
24
|
+
/** True when the text of a user message came from another agent's tooling: a Reach ring or seat start, or a heartbeat wake. */
|
|
25
|
+
function isAgentText(text) {
|
|
26
|
+
return text.includes(REACH_RING_MARKER) || text.includes(REACH_SEAT_START_MARKER) || HEARTBEAT_TICK.test(text);
|
|
27
|
+
}
|
|
28
|
+
const activityCursorSchema = z.object({
|
|
29
|
+
offset: z.number().int().nonnegative(),
|
|
30
|
+
lastTs: z.number().int().nonnegative(),
|
|
31
|
+
runStart: z.number().int().nonnegative(),
|
|
32
|
+
agentDriven: z.boolean(),
|
|
33
|
+
counted: z.boolean(),
|
|
34
|
+
path: z.string().min(1).optional()
|
|
35
|
+
});
|
|
36
|
+
const EMPTY_ACTIVITY_CURSOR = {
|
|
37
|
+
offset: 0,
|
|
38
|
+
lastTs: 0,
|
|
39
|
+
runStart: 0,
|
|
40
|
+
agentDriven: false,
|
|
41
|
+
counted: false
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Folds the records after the cursor into one window. A gap belongs to the window of the record
|
|
45
|
+
* that ends it, so splitting a transcript at any line gives the same sums. A run is counted in
|
|
46
|
+
* runs_over_12h once, in the send where it reaches 12 hours, whether or not it has ended. longest_run_ms
|
|
47
|
+
* is the longest run of any length seen in the window, including the length so far of the run still
|
|
48
|
+
* open. `closeRun` ends the run still open at the last record (the session is over); otherwise it
|
|
49
|
+
* stays open in the cursor.
|
|
50
|
+
*/
|
|
51
|
+
function foldActivity(cursor, records, closeRun) {
|
|
52
|
+
let { lastTs, runStart, agentDriven, counted } = cursor;
|
|
53
|
+
let windowStart = runStart > 0 ? lastTs : 0;
|
|
54
|
+
let active = 0, team = 0, human = 0, runs = 0, longest = 0;
|
|
55
|
+
const seeRun = (end) => {
|
|
56
|
+
const length = end - runStart;
|
|
57
|
+
longest = Math.max(longest, length);
|
|
58
|
+
if (!counted && length >= ACTIVITY_LONG_RUN_MS) {
|
|
59
|
+
runs++;
|
|
60
|
+
counted = true;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
for (const record of records) {
|
|
64
|
+
if (windowStart === 0) windowStart = record.ts;
|
|
65
|
+
if (runStart === 0) runStart = record.ts;
|
|
66
|
+
else if (record.ts > lastTs) {
|
|
67
|
+
const gap = record.ts - lastTs;
|
|
68
|
+
if (gap <= ACTIVITY_GAP_MS) {
|
|
69
|
+
active += gap;
|
|
70
|
+
if (agentDriven) team += gap;
|
|
71
|
+
} else {
|
|
72
|
+
seeRun(lastTs);
|
|
73
|
+
runStart = record.ts;
|
|
74
|
+
counted = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
lastTs = Math.max(lastTs, record.ts);
|
|
78
|
+
if (runStart > 0) seeRun(lastTs);
|
|
79
|
+
if (record.incoming === "human") {
|
|
80
|
+
human++;
|
|
81
|
+
agentDriven = false;
|
|
82
|
+
} else if (record.incoming === "agent") agentDriven = true;
|
|
83
|
+
}
|
|
84
|
+
const closing = closeRun && runStart > 0;
|
|
85
|
+
if (closing) {
|
|
86
|
+
runStart = 0;
|
|
87
|
+
counted = false;
|
|
88
|
+
}
|
|
89
|
+
const next = {
|
|
90
|
+
...cursor,
|
|
91
|
+
lastTs,
|
|
92
|
+
runStart,
|
|
93
|
+
agentDriven,
|
|
94
|
+
counted
|
|
95
|
+
};
|
|
96
|
+
if (records.length === 0 && !closing) return {
|
|
97
|
+
payload: void 0,
|
|
98
|
+
cursor: next
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
payload: {
|
|
102
|
+
window_start_ms: windowStart > 0 ? windowStart : lastTs,
|
|
103
|
+
window_end_ms: lastTs,
|
|
104
|
+
active_ms: active,
|
|
105
|
+
team_driven_ms: team,
|
|
106
|
+
runs_over_12h: runs,
|
|
107
|
+
longest_run_ms: longest,
|
|
108
|
+
human_messages: human
|
|
109
|
+
},
|
|
110
|
+
cursor: next
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Calls `onLine` for each complete line from `offset` on and returns the offset after the last
|
|
115
|
+
* complete line, and whether the end of the file was reached on a line boundary. Stops reading once `budgetMs` has passed: real transcripts reach 1.65 GB while a
|
|
116
|
+
* hook has seconds, so a long backlog is taken over several sends instead of failing every time.
|
|
117
|
+
*/
|
|
118
|
+
async function readNewLines(path, offset, budgetMs, onLine) {
|
|
119
|
+
const deadline = Date.now() + budgetMs;
|
|
120
|
+
const stream = createReadStream(path, { start: offset });
|
|
121
|
+
let pending = Buffer.alloc(0);
|
|
122
|
+
let consumed = offset;
|
|
123
|
+
let done = true;
|
|
124
|
+
try {
|
|
125
|
+
for await (const chunk of stream) {
|
|
126
|
+
pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]);
|
|
127
|
+
let from = 0;
|
|
128
|
+
for (let end = pending.indexOf(10, from); end !== -1; end = pending.indexOf(10, from)) {
|
|
129
|
+
if (end > from) onLine(pending.toString("utf8", from, end));
|
|
130
|
+
from = end + 1;
|
|
131
|
+
}
|
|
132
|
+
consumed += from;
|
|
133
|
+
pending = pending.subarray(from);
|
|
134
|
+
if (Date.now() > deadline) {
|
|
135
|
+
done = false;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
stream.destroy();
|
|
141
|
+
}
|
|
142
|
+
if (done && pending.length > 0) {
|
|
143
|
+
const tail = pending.toString("utf8");
|
|
144
|
+
try {
|
|
145
|
+
JSON.parse(tail);
|
|
146
|
+
onLine(tail);
|
|
147
|
+
consumed += pending.length;
|
|
148
|
+
} catch {
|
|
149
|
+
done = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
offset: consumed,
|
|
154
|
+
done
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// config/agent-state.ts
|
|
158
|
+
const execute = promisify(execFile);
|
|
159
|
+
const harnessSchema = z.enum([
|
|
160
|
+
"claude",
|
|
161
|
+
"codex",
|
|
162
|
+
"cursor",
|
|
163
|
+
"hermes",
|
|
164
|
+
"openclaw",
|
|
165
|
+
"unknown"
|
|
166
|
+
]);
|
|
167
|
+
const observationSchema = z.object({
|
|
168
|
+
harness: harnessSchema,
|
|
169
|
+
sessionId: z.string().min(1),
|
|
170
|
+
at: z.iso.datetime({ offset: true }),
|
|
171
|
+
event: z.string(),
|
|
172
|
+
input: z.record(z.string(), z.unknown()),
|
|
173
|
+
pid: z.number().int().positive().optional(),
|
|
174
|
+
processStart: z.string().optional(),
|
|
175
|
+
reachAddress: z.string().optional(),
|
|
176
|
+
cwd: z.string().optional()
|
|
177
|
+
});
|
|
178
|
+
function record(value) {
|
|
179
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
180
|
+
}
|
|
181
|
+
function text(value) {
|
|
182
|
+
return typeof value === "string" ? value : "";
|
|
183
|
+
}
|
|
184
|
+
function profileDir() {
|
|
185
|
+
return process.env.SNO_PROFILE_DIR ?? join(homedir(), ".sno");
|
|
186
|
+
}
|
|
187
|
+
function eventFile(profile, harness, sessionId) {
|
|
188
|
+
return join(profile, "station", "agents", `${encodeURIComponent(harness)}--${encodeURIComponent(sessionId)}.jsonl`);
|
|
189
|
+
}
|
|
190
|
+
function failure(operation, target, error, impact) {
|
|
191
|
+
return {
|
|
192
|
+
operation,
|
|
193
|
+
target,
|
|
194
|
+
error: error instanceof Error ? error.message : String(error),
|
|
195
|
+
impact
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function absent(error) {
|
|
199
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
200
|
+
}
|
|
201
|
+
/** Linux start ticks identify one process lifetime, including across PID reuse. */
|
|
202
|
+
async function agentProcessIdentity(pid) {
|
|
203
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
204
|
+
const processStart = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
|
205
|
+
if (!processStart) throw new Error("process start time unavailable");
|
|
206
|
+
return {
|
|
207
|
+
pid,
|
|
208
|
+
processStart
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async function observedProcess(harness, input) {
|
|
212
|
+
const paths = (process.env.PATH ?? "").split(":");
|
|
213
|
+
const command = harness === "cursor" ? "cursor-agent" : harness;
|
|
214
|
+
const binaries = [];
|
|
215
|
+
for (const path of paths) try {
|
|
216
|
+
binaries.push(await realpath(join(path, command)));
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (!absent(error)) throw error;
|
|
219
|
+
}
|
|
220
|
+
const supplied = Number(input.agent_pid ?? process.env.CLAUDE_CODE_PID ?? process.env.CODEX_PID);
|
|
221
|
+
let pid = process.ppid;
|
|
222
|
+
while (pid > 1) {
|
|
223
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
224
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
225
|
+
const exe = await realpath(`/proc/${pid}/exe`);
|
|
226
|
+
const cmdline = (await readFile(`/proc/${pid}/cmdline`, "utf8")).split("\0");
|
|
227
|
+
if (pid === supplied || binaries.includes(exe) || cmdline.some((argument) => binaries.includes(argument))) return agentProcessIdentity(pid);
|
|
228
|
+
pid = Number(fields[1]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const OBSERVED_FIELDS = [
|
|
232
|
+
"tool_name",
|
|
233
|
+
"tool_use_id",
|
|
234
|
+
"call_id",
|
|
235
|
+
"notification_type",
|
|
236
|
+
"error",
|
|
237
|
+
"error_type",
|
|
238
|
+
"approved",
|
|
239
|
+
"decision",
|
|
240
|
+
"choice",
|
|
241
|
+
"human_addressee",
|
|
242
|
+
"review_requested",
|
|
243
|
+
"decision_required",
|
|
244
|
+
"context_exhausted",
|
|
245
|
+
"reference",
|
|
246
|
+
"transcript_path"
|
|
247
|
+
];
|
|
248
|
+
const OBSERVED_TEXT_CHARS = 200;
|
|
249
|
+
function observedInput(input) {
|
|
250
|
+
const kept = Object.fromEntries(OBSERVED_FIELDS.filter((key) => input[key] !== void 0).map((key) => [key, input[key]]));
|
|
251
|
+
const prompt = text(input.prompt);
|
|
252
|
+
if (prompt) kept.prompt = prompt.slice(0, OBSERVED_TEXT_CHARS);
|
|
253
|
+
const reply = text(input.last_assistant_message ?? input.text ?? input.response ?? input["last-assistant-message"]);
|
|
254
|
+
if (reply) kept.last_assistant_message = reply.trimEnd().slice(-200);
|
|
255
|
+
if (input.tool_response !== void 0) kept.tool_response = { accepted: record(input.tool_response).accepted === true };
|
|
256
|
+
if (input.tool_output !== void 0) kept.tool_output = true;
|
|
257
|
+
return kept;
|
|
258
|
+
}
|
|
259
|
+
/** Observational failures are logged; they never prevent an agent's ordinary hook. */
|
|
260
|
+
async function observeAgentEvent(harness, event, raw) {
|
|
261
|
+
const input = record(raw);
|
|
262
|
+
const sessionId = text(harness === "cursor" ? input.conversation_id : input.session_id ?? input.thread_id);
|
|
263
|
+
let target = `${harness}/${sessionId || "missing session identity"}`;
|
|
264
|
+
try {
|
|
265
|
+
if (!sessionId) throw new Error("host supplied no session identity");
|
|
266
|
+
if (typeof input.agent_id === "string" && input.agent_id && input.agent_id !== sessionId) {
|
|
267
|
+
await observeAgentEvent(harness, event, {
|
|
268
|
+
...input,
|
|
269
|
+
session_id: input.agent_id,
|
|
270
|
+
parent_session_id: void 0
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const thread = text(input.thread_id ?? input["thread-id"]);
|
|
275
|
+
if (event === "notify" && thread && thread !== sessionId) return;
|
|
276
|
+
let identity;
|
|
277
|
+
try {
|
|
278
|
+
identity = await observedProcess(harness, input);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
process.stderr.write(`${JSON.stringify(failure("inspect agent process", target, error, "process observation unknown"))}\n`);
|
|
281
|
+
}
|
|
282
|
+
let cwd = text(input.cwd);
|
|
283
|
+
if (!cwd && harness === "cursor" && Array.isArray(input.workspace_roots)) cwd = text(input.workspace_roots[0]);
|
|
284
|
+
target = eventFile(profileDir(), harness, sessionId);
|
|
285
|
+
const observation = {
|
|
286
|
+
harness,
|
|
287
|
+
sessionId,
|
|
288
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
289
|
+
event: event === "user-prompt-submit" && isAgentText(text(input.prompt)) ? "agent-prompt-submit" : event,
|
|
290
|
+
input: observedInput(input),
|
|
291
|
+
...identity,
|
|
292
|
+
...process.env.SNO_REACH_ADDR && input.agent_id === void 0 ? { reachAddress: process.env.SNO_REACH_ADDR } : {},
|
|
293
|
+
...cwd ? { cwd } : {}
|
|
294
|
+
};
|
|
295
|
+
await mkdir(dirname(target), {
|
|
296
|
+
recursive: true,
|
|
297
|
+
mode: 448
|
|
298
|
+
});
|
|
299
|
+
await appendFile(target, `${JSON.stringify(observation)}\n`, { mode: 384 });
|
|
300
|
+
} catch (error) {
|
|
301
|
+
process.stderr.write(`${JSON.stringify(failure("append agent observation", target, error, `${event} observation missing`))}\n`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function emptyAgent(harness, sessionId) {
|
|
305
|
+
return {
|
|
306
|
+
harness,
|
|
307
|
+
sessionId,
|
|
308
|
+
reachAddress: "unknown",
|
|
309
|
+
cwd: "unknown",
|
|
310
|
+
description: "unknown",
|
|
311
|
+
doing: "unknown",
|
|
312
|
+
process: "unknown",
|
|
313
|
+
resumable: "unknown",
|
|
314
|
+
waitingForHuman: "unknown",
|
|
315
|
+
waitingReason: "unknown",
|
|
316
|
+
group: "unknown",
|
|
317
|
+
subgroup: "unknown",
|
|
318
|
+
lastEvent: "unknown",
|
|
319
|
+
ageSeconds: "unknown",
|
|
320
|
+
resultId: "unknown",
|
|
321
|
+
humanSeen: "unknown",
|
|
322
|
+
finalReply: "unknown",
|
|
323
|
+
work: [],
|
|
324
|
+
delivery: "unknown",
|
|
325
|
+
resumeEvidence: "unknown",
|
|
326
|
+
missingReasons: []
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function askTool(input) {
|
|
330
|
+
return /(?:^|\.)(?:AskUserQuestion|request_user_input_async|request_user_input)$/.test(text(input.tool_name));
|
|
331
|
+
}
|
|
332
|
+
function wait(row, reason) {
|
|
333
|
+
row.waitingForHuman = "yes";
|
|
334
|
+
row.waitingReason = reason;
|
|
335
|
+
}
|
|
336
|
+
function resolveWait(row) {
|
|
337
|
+
row.waitingForHuman = "no";
|
|
338
|
+
row.waitingReason = "unknown";
|
|
339
|
+
}
|
|
340
|
+
function matchingTool(permission, input) {
|
|
341
|
+
if (!permission) return false;
|
|
342
|
+
const requested = text(permission.input.tool_use_id ?? permission.input.call_id);
|
|
343
|
+
const completed = text(input.tool_use_id ?? input.call_id);
|
|
344
|
+
if (requested && completed) return requested === completed;
|
|
345
|
+
const name = text(input.tool_name);
|
|
346
|
+
return Boolean(name && name === permission.input.tool_name);
|
|
347
|
+
}
|
|
348
|
+
function applyEvent(row, observation, index, source, permission) {
|
|
349
|
+
const { input, at } = observation;
|
|
350
|
+
const event = observation.event === "user-prompt-submit" && text(input.prompt).trimStart().startsWith("<task-notification>") ? "agent-prompt-submit" : observation.event;
|
|
351
|
+
if (event === "handoff-released") {
|
|
352
|
+
row.work.push({
|
|
353
|
+
...input,
|
|
354
|
+
releasedAt: at
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (event === "human-seen") {
|
|
359
|
+
if (input.resultId === row.resultId) row.humanSeen = true;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
row.lastEvent = {
|
|
363
|
+
at,
|
|
364
|
+
event,
|
|
365
|
+
source
|
|
366
|
+
};
|
|
367
|
+
row.reachAddress = observation.reachAddress ?? row.reachAddress;
|
|
368
|
+
row.cwd = observation.cwd ?? row.cwd;
|
|
369
|
+
if ([
|
|
370
|
+
"user-prompt-submit",
|
|
371
|
+
"agent-prompt-submit",
|
|
372
|
+
"pre-tool-use",
|
|
373
|
+
"pre-model-call",
|
|
374
|
+
"after-agent-thought"
|
|
375
|
+
].includes(event)) row.doing = "working";
|
|
376
|
+
if (event === "user-prompt-submit") {
|
|
377
|
+
resolveWait(row);
|
|
378
|
+
row.resultId = "unknown";
|
|
379
|
+
row.humanSeen = "unknown";
|
|
380
|
+
row.subgroup = "unknown";
|
|
381
|
+
row.delivery = "unknown";
|
|
382
|
+
row.finalReply = "unknown";
|
|
383
|
+
row.description = text(input.prompt) || "unknown";
|
|
384
|
+
}
|
|
385
|
+
if (event === "permission-request") wait(row, askTool(input) ? "asked a question" : "permission to approve");
|
|
386
|
+
if (event === "notification" && input.notification_type === "permission_prompt" && row.waitingForHuman === "yes") return;
|
|
387
|
+
if (["pre-tool-use", "permission-request"].includes(event) && askTool(input)) wait(row, "asked a question");
|
|
388
|
+
if (event === "post-tool-use") {
|
|
389
|
+
const response = record(input.tool_response);
|
|
390
|
+
if (matchingTool(permission, input) && (askTool(input) && (input.tool_response !== void 0 || input.tool_output !== void 0) || !askTool(input) && row.waitingReason === "permission to approve")) resolveWait(row);
|
|
391
|
+
if (askTool(input) && row.harness === "codex" && response.accepted === true) wait(row, "asked a question");
|
|
392
|
+
}
|
|
393
|
+
if (event === "approval-response") {
|
|
394
|
+
if (input.approved === true || input.decision === "allow" || input.choice === "allow") resolveWait(row);
|
|
395
|
+
else {
|
|
396
|
+
row.waitingForHuman = "unknown";
|
|
397
|
+
row.waitingReason = "unknown";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if ([
|
|
401
|
+
"stop",
|
|
402
|
+
"after-agent-response",
|
|
403
|
+
"notify"
|
|
404
|
+
].includes(event)) {
|
|
405
|
+
const reply = text(input.last_assistant_message ?? input.text ?? input.response ?? input["last-assistant-message"]);
|
|
406
|
+
if (reply) row.finalReply = reply;
|
|
407
|
+
if (event !== "after-agent-response") {
|
|
408
|
+
row.doing = "idle at prompt";
|
|
409
|
+
row.resultId = `${row.harness}/${row.sessionId}/${index}`;
|
|
410
|
+
row.humanSeen = false;
|
|
411
|
+
}
|
|
412
|
+
if (/[??]$/.test(row.finalReply.trimEnd())) wait(row, "asked a question");
|
|
413
|
+
}
|
|
414
|
+
if (event === "interrupt") {
|
|
415
|
+
row.doing = "idle at prompt";
|
|
416
|
+
if (row.waitingReason === "permission to approve") {
|
|
417
|
+
row.waitingForHuman = "unknown";
|
|
418
|
+
row.waitingReason = "unknown";
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (event === "session-end") row.doing = "idle at prompt";
|
|
422
|
+
if (event === "stop-failure" && [
|
|
423
|
+
"rate_limit",
|
|
424
|
+
"rate_limit_error",
|
|
425
|
+
"quota_exhausted"
|
|
426
|
+
].includes(text(input.error ?? input.error_type))) wait(row, "quota exhausted");
|
|
427
|
+
if (input.human_addressee === true && input.review_requested === true) wait(row, "done awaiting review");
|
|
428
|
+
if (input.human_addressee === true && input.decision_required === true) wait(row, "failed awaiting decision");
|
|
429
|
+
if (input.context_exhausted === true) row.resumable = "context full";
|
|
430
|
+
if (event === "delivered" && text(input.reference)) {
|
|
431
|
+
row.delivery = text(input.reference);
|
|
432
|
+
row.subgroup = "delivered";
|
|
433
|
+
}
|
|
434
|
+
if (event === "ended-no-change") row.subgroup = "ended with no change";
|
|
435
|
+
}
|
|
436
|
+
async function inspectProcess(row, observations, errors) {
|
|
437
|
+
if (process.platform !== "linux") {
|
|
438
|
+
row.missingReasons.push("agent process start inspection unsupported on this platform");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const observed = observations.findLast((item) => item.pid !== void 0 && item.processStart !== void 0);
|
|
442
|
+
if (!observed?.pid) {
|
|
443
|
+
row.missingReasons.push("actual agent PID and start time unavailable");
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
row.process = (await agentProcessIdentity(observed.pid)).processStart === observed.processStart ? "present" : "exited";
|
|
448
|
+
if (row.process === "present") {
|
|
449
|
+
if (row.resumable !== "context full") row.resumable = "live";
|
|
450
|
+
const environment = (await readFile(`/proc/${observed.pid}/environ`, "utf8")).split("\0");
|
|
451
|
+
const socket = environment.find((item) => item.startsWith("TMUX="))?.slice(5).split(",")[0];
|
|
452
|
+
const pane = environment.find((item) => item.startsWith("TMUX_PANE="))?.slice(10);
|
|
453
|
+
if (socket && pane) try {
|
|
454
|
+
row.process = (await execute("tmux", [
|
|
455
|
+
"-S",
|
|
456
|
+
socket,
|
|
457
|
+
"display-message",
|
|
458
|
+
"-p",
|
|
459
|
+
"-t",
|
|
460
|
+
pane,
|
|
461
|
+
"#{session_attached}"
|
|
462
|
+
])).stdout.trim() === "0" ? "detached" : "present";
|
|
463
|
+
} catch (error) {
|
|
464
|
+
row.process = "unknown";
|
|
465
|
+
errors.push(failure("inspect tmux attachment", pane, error, "process attachment unknown"));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (absent(error)) row.process = "exited";
|
|
470
|
+
else {
|
|
471
|
+
row.process = "unknown";
|
|
472
|
+
errors.push(failure("inspect process", String(observed.pid), error, "process unknown"));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function inspectResume(row, observations, errors) {
|
|
477
|
+
if (row.process !== "exited" || row.resumable === "context full") return;
|
|
478
|
+
const transcript = observations.map((item) => text(item.input.transcript_path)).findLast(Boolean);
|
|
479
|
+
if (!transcript || !["claude", "codex"].includes(row.harness)) return;
|
|
480
|
+
const stream = createReadStream(transcript, "utf8");
|
|
481
|
+
try {
|
|
482
|
+
for await (const line of createInterface({
|
|
483
|
+
input: stream,
|
|
484
|
+
crlfDelay: Number.POSITIVE_INFINITY
|
|
485
|
+
})) {
|
|
486
|
+
if (!line.trim()) continue;
|
|
487
|
+
const item = record(JSON.parse(line));
|
|
488
|
+
if (row.harness === "codex" ? item.type === "session_meta" && record(item.payload).id === row.sessionId : item.sessionId === row.sessionId && ["user", "assistant"].includes(text(item.type))) {
|
|
489
|
+
row.resumable = "resumable";
|
|
490
|
+
row.resumeEvidence = transcript;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
errors.push(failure("read resume record", transcript, error, "resume availability unknown"));
|
|
496
|
+
} finally {
|
|
497
|
+
stream.destroy();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/** Claude fires no hook when a person denies a permission prompt; only its transcript records the rejected call. */
|
|
501
|
+
async function permissionDenied(row, observations, errors) {
|
|
502
|
+
const at = observations.findLastIndex((item) => item.event === "permission-request");
|
|
503
|
+
const request = observations[at];
|
|
504
|
+
if (row.harness !== "claude" || !request) return false;
|
|
505
|
+
const call = text(request.input.tool_use_id) || text(observations.slice(0, at).findLast((item) => item.event === "pre-tool-use" && item.input.tool_name === request.input.tool_name)?.input.tool_use_id);
|
|
506
|
+
const transcript = text(request.input.transcript_path);
|
|
507
|
+
if (!call || !transcript) return false;
|
|
508
|
+
const handle = await open(transcript, "r").catch((error) => {
|
|
509
|
+
errors.push(failure("read permission outcome", transcript, error, "a denied permission still shows as waiting"));
|
|
510
|
+
});
|
|
511
|
+
if (!handle) return false;
|
|
512
|
+
try {
|
|
513
|
+
const { size } = await handle.stat();
|
|
514
|
+
const start = Math.max(0, size - 262144);
|
|
515
|
+
const { buffer, bytesRead } = await handle.read(Buffer.alloc(size - start), 0, size - start, start);
|
|
516
|
+
return buffer.subarray(0, bytesRead).toString("utf8").split("\n").some((line) => line.includes(`"tool_use_id":"${call}"`) && line.includes("\"toolUseResult\":\"User rejected tool use\""));
|
|
517
|
+
} catch (error) {
|
|
518
|
+
errors.push(failure("read permission outcome", transcript, error, "a denied permission still shows as waiting"));
|
|
519
|
+
return false;
|
|
520
|
+
} finally {
|
|
521
|
+
await handle.close();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function classify(row) {
|
|
525
|
+
if (row.waitingForHuman === "yes") {
|
|
526
|
+
row.group = "Needs you";
|
|
527
|
+
row.subgroup = row.waitingReason;
|
|
528
|
+
} else if (row.doing === "working") {
|
|
529
|
+
row.group = "Working";
|
|
530
|
+
row.subgroup = "working";
|
|
531
|
+
} else if (row.resultId !== "unknown" || row.resumable === "resumable") {
|
|
532
|
+
row.group = "Finished";
|
|
533
|
+
if (row.subgroup === "unknown" && row.resumable === "resumable") row.subgroup = "resumable";
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async function readAgentFile(path, now, errors) {
|
|
537
|
+
const observations = [];
|
|
538
|
+
let unreadable = false;
|
|
539
|
+
const contents = await readFile(path, "utf8");
|
|
540
|
+
for (const [index, line] of contents.split("\n").entries()) {
|
|
541
|
+
if (!line.trim()) continue;
|
|
542
|
+
try {
|
|
543
|
+
observations.push(observationSchema.parse(JSON.parse(line)));
|
|
544
|
+
} catch (error) {
|
|
545
|
+
unreadable = true;
|
|
546
|
+
errors.push(failure("parse agent observation", `${path}:${index + 1}`, error, "current agent observation unknown"));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const first = observations[0];
|
|
550
|
+
if (!first) {
|
|
551
|
+
const name = path.slice(path.lastIndexOf("/") + 1, -6);
|
|
552
|
+
const separator = name.indexOf("--");
|
|
553
|
+
const harness = harnessSchema.safeParse(decodeURIComponent(name.slice(0, separator)));
|
|
554
|
+
if (!harness.success || separator < 0) return void 0;
|
|
555
|
+
const row = emptyAgent(harness.data, decodeURIComponent(name.slice(separator + 2)));
|
|
556
|
+
row.missingReasons.push("unreadable observation");
|
|
557
|
+
return row;
|
|
558
|
+
}
|
|
559
|
+
const row = emptyAgent(first.harness, first.sessionId);
|
|
560
|
+
let permission;
|
|
561
|
+
let waitAt;
|
|
562
|
+
for (const [index, observation] of observations.entries()) {
|
|
563
|
+
if (observation.sessionId !== row.sessionId || observation.harness !== row.harness) {
|
|
564
|
+
unreadable = true;
|
|
565
|
+
errors.push(failure("read agent identity", path, "mixed identities in event file", "current agent observation unknown"));
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const wasWaiting = row.waitingForHuman;
|
|
569
|
+
if (["permission-request", "pre-tool-use"].includes(observation.event) && (observation.event === "permission-request" || askTool(observation.input))) permission = observation;
|
|
570
|
+
applyEvent(row, observation, index, path, permission);
|
|
571
|
+
if (row.waitingForHuman === "yes" && wasWaiting !== "yes") waitAt = observation.at;
|
|
572
|
+
if (row.waitingForHuman !== "yes") waitAt = void 0;
|
|
573
|
+
}
|
|
574
|
+
if (row.waitingReason === "permission to approve" && await permissionDenied(row, observations, errors)) {
|
|
575
|
+
row.doing = "idle at prompt";
|
|
576
|
+
resolveWait(row);
|
|
577
|
+
row.group = "Finished";
|
|
578
|
+
row.subgroup = "permission denied";
|
|
579
|
+
}
|
|
580
|
+
await inspectProcess(row, observations, errors);
|
|
581
|
+
await inspectResume(row, observations, errors);
|
|
582
|
+
if (row.lastEvent !== "unknown") row.ageSeconds = Math.max(0, (now - Date.parse(row.lastEvent.at)) / 1e3);
|
|
583
|
+
if (unreadable || typeof row.ageSeconds === "number" && row.ageSeconds > 900) {
|
|
584
|
+
if (row.doing === "working") row.doing = "unknown";
|
|
585
|
+
if (row.waitingForHuman === "yes") row.waitingForHuman = "unknown";
|
|
586
|
+
row.missingReasons.push(unreadable ? "unreadable observation" : "current observation older than 900 seconds");
|
|
587
|
+
}
|
|
588
|
+
if (row.waitingForHuman === "yes" && waitAt && now - Date.parse(waitAt) > 9e5) {
|
|
589
|
+
row.waitingForHuman = "unknown";
|
|
590
|
+
row.missingReasons.push("human-wait observation older than 900 seconds");
|
|
591
|
+
}
|
|
592
|
+
classify(row);
|
|
593
|
+
return row;
|
|
594
|
+
}
|
|
595
|
+
async function readSeats(root, rows, errors) {
|
|
596
|
+
let entries;
|
|
597
|
+
try {
|
|
598
|
+
entries = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (!absent(error)) errors.push(failure("read Reach inventory", root, error, "Reach seats unknown"));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
for (const address of entries) {
|
|
604
|
+
const path = join(root, address, "seat.json");
|
|
605
|
+
try {
|
|
606
|
+
const seat = record(JSON.parse(await readFile(path, "utf8")));
|
|
607
|
+
if (rows.some((row) => row.reachAddress === address)) continue;
|
|
608
|
+
let runtime = text(seat.runtime);
|
|
609
|
+
if (!runtime) try {
|
|
610
|
+
runtime = text(record(JSON.parse(await readFile(join(root, address, "reachable.json"), "utf8"))).harness);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (!absent(error)) errors.push(failure("read Reach harness", address, error, "harness unknown"));
|
|
613
|
+
}
|
|
614
|
+
const parsed = harnessSchema.safeParse(runtime === "cursor-agent" ? "cursor" : runtime === "claude-code" ? "claude" : runtime);
|
|
615
|
+
const row = emptyAgent(parsed.success ? parsed.data : "unknown", text(seat.session_id) || "unknown");
|
|
616
|
+
row.reachAddress = address;
|
|
617
|
+
row.missingReasons.push(row.harness === "openclaw" ? "OpenClaw state capture unsupported" : "Reach seat has no captured events");
|
|
618
|
+
rows.push(row);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
if (!absent(error)) errors.push(failure("read Reach seat", path, error, "seat observations unknown"));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function reachWork(options, errors) {
|
|
625
|
+
let program = options.reachProgram;
|
|
626
|
+
try {
|
|
627
|
+
if (!program) {
|
|
628
|
+
const entry = text(record(record(record(JSON.parse(await readFile(join(homedir(), ".config/sno/assemble.json"), "utf8"))).programs).reach).entry);
|
|
629
|
+
if (!entry) return [];
|
|
630
|
+
program = join(dirname(entry), "../lib/reach-agent-view");
|
|
631
|
+
}
|
|
632
|
+
return (await execute(program, [], { env: {
|
|
633
|
+
...process.env,
|
|
634
|
+
...options.reachRoot ? { SNO_REACH_ROOT: options.reachRoot } : {}
|
|
635
|
+
} })).stdout.trim().split("\n").filter(Boolean).map((line) => record(JSON.parse(line)));
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (!program && absent(error)) return [];
|
|
638
|
+
errors.push(failure("read Reach work", program ?? "Reach installation", error, "failed work observations unknown; other records retained"));
|
|
639
|
+
const output = record(error).stdout;
|
|
640
|
+
if (typeof output !== "string") return [];
|
|
641
|
+
const usable = [];
|
|
642
|
+
for (const line of output.split("\n").filter(Boolean)) try {
|
|
643
|
+
usable.push(record(JSON.parse(line)));
|
|
644
|
+
} catch (parseError) {
|
|
645
|
+
errors.push(failure("parse Reach work", program ?? "Reach", parseError, "one work observation unknown"));
|
|
646
|
+
}
|
|
647
|
+
return usable;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async function readAgentView(options = {}) {
|
|
651
|
+
const now = options.now ?? Date.now();
|
|
652
|
+
const directory = join(options.profileDir ?? profileDir(), "station", "agents");
|
|
653
|
+
const view = {
|
|
654
|
+
observedAt: new Date(now).toISOString(),
|
|
655
|
+
agents: [],
|
|
656
|
+
work: [],
|
|
657
|
+
quota: [],
|
|
658
|
+
errors: []
|
|
659
|
+
};
|
|
660
|
+
let files = [];
|
|
661
|
+
try {
|
|
662
|
+
files = await readdir(directory);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
if (!absent(error)) throw new Error(`read agent inventory ${directory}: ${String(error)}`);
|
|
665
|
+
}
|
|
666
|
+
for (const name of files.filter((name) => name.endsWith(".jsonl")).sort()) try {
|
|
667
|
+
if (options.recent && now - (await stat(join(directory, name))).mtimeMs > 9e5) continue;
|
|
668
|
+
const row = await readAgentFile(join(directory, name), now, view.errors);
|
|
669
|
+
if (row) view.agents.push(row);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
view.errors.push(failure("read agent events", join(directory, name), error, "agent observations unknown"));
|
|
672
|
+
}
|
|
673
|
+
const root = options.reachRoot ?? process.env.SNO_REACH_ROOT ?? join(homedir(), ".local/state/sno-reach");
|
|
674
|
+
await readSeats(root, view.agents, view.errors);
|
|
675
|
+
view.work = options.recent ? [] : await reachWork({
|
|
676
|
+
...options,
|
|
677
|
+
reachRoot: root
|
|
678
|
+
}, view.errors);
|
|
679
|
+
for (const item of view.work.filter((item) => item.handoff_error)) view.errors.push(failure("associate Reach handoff", text(item.message_id), item.handoff_error, "work ownership unknown"));
|
|
680
|
+
const releases = view.agents.flatMap((row) => row.work);
|
|
681
|
+
for (const release of releases) {
|
|
682
|
+
const item = view.work.find((item) => item.message_id === release.message_id && item.work === release.work && item.recipient === release.source && item.handoff_to === release.target && item.handoff_message_id === release.target_message_id);
|
|
683
|
+
if (item) {
|
|
684
|
+
item.owner = release.target;
|
|
685
|
+
item.releasedAt = release.releasedAt;
|
|
686
|
+
item.state = item.handoff_state;
|
|
687
|
+
item.since = item.handoff_since;
|
|
688
|
+
item.blocked_on = item.handoff_blocked_on;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
for (const row of view.agents) {
|
|
692
|
+
row.work = view.work.filter((item) => item.owner === row.reachAddress);
|
|
693
|
+
if (row.work.find((item) => item.blocked_on && item.blocked_on !== "-") && row.group === "Working") row.subgroup = "waiting on another agent";
|
|
694
|
+
if (row.waitingForHuman !== "yes" && row.work.some((item) => item.handoff_to && !item.releasedAt)) {
|
|
695
|
+
row.group = "Working";
|
|
696
|
+
row.subgroup = "handing over";
|
|
697
|
+
}
|
|
698
|
+
const released = view.work.filter((item) => item.recipient === row.reachAddress && item.releasedAt).map((item) => text(item.releasedAt)).sort().at(-1);
|
|
699
|
+
const workingSince = row.doing === "working" && row.lastEvent !== "unknown" && released !== void 0 && row.lastEvent.at > released;
|
|
700
|
+
if (row.waitingForHuman !== "yes" && released !== void 0 && !workingSince && !row.work.some((item) => ![
|
|
701
|
+
"completed",
|
|
702
|
+
"failed",
|
|
703
|
+
"cancelled",
|
|
704
|
+
"refused",
|
|
705
|
+
"dismissed",
|
|
706
|
+
"expired",
|
|
707
|
+
"superseded"
|
|
708
|
+
].includes(text(item.state)))) {
|
|
709
|
+
row.group = "Finished";
|
|
710
|
+
row.subgroup = "handed over";
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
view.quota = [...new Set(view.agents.map((row) => row.harness))].map((harness) => ({
|
|
714
|
+
harness,
|
|
715
|
+
state: "unknown",
|
|
716
|
+
reason: "no quota observation; use explicit refresh",
|
|
717
|
+
source: "unknown",
|
|
718
|
+
observedAt: "unknown"
|
|
719
|
+
}));
|
|
720
|
+
const groups = [
|
|
721
|
+
"Needs you",
|
|
722
|
+
"Working",
|
|
723
|
+
"Finished",
|
|
724
|
+
"unknown"
|
|
725
|
+
];
|
|
726
|
+
const age = (row) => row.ageSeconds === "unknown" ? Number.POSITIVE_INFINITY : row.ageSeconds;
|
|
727
|
+
view.agents.sort((a, b) => groups.indexOf(a.group) - groups.indexOf(b.group) || Number(a.humanSeen === true) - Number(b.humanSeen === true) || age(a) - age(b));
|
|
728
|
+
return view;
|
|
729
|
+
}
|
|
730
|
+
/** Called only after Reach verifies the receiver's release response. */
|
|
731
|
+
async function recordAgentHandoff(source, target, releaseText, options = {}) {
|
|
732
|
+
const view = await readAgentView(options);
|
|
733
|
+
const candidates = view.work.filter((item) => item.handoff_to === target && (!source || item.handoff_sender === source) && typeof item.handoff_message_id === "string" && releaseText.includes(item.handoff_message_id));
|
|
734
|
+
if (candidates.length !== 1) throw new Error("release has no unique linked work card; ownership remains unknown");
|
|
735
|
+
const item = candidates[0];
|
|
736
|
+
const at = (row) => row.lastEvent === "unknown" ? 0 : Date.parse(row.lastEvent.at);
|
|
737
|
+
const agent = view.agents.filter((row) => row.reachAddress === target && row.sessionId !== "unknown").sort((a, b) => at(b) - at(a))[0];
|
|
738
|
+
if (!item || !agent) throw new Error("receiver has no captured session; release observation unavailable");
|
|
739
|
+
const observation = {
|
|
740
|
+
harness: agent.harness,
|
|
741
|
+
sessionId: agent.sessionId,
|
|
742
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
743
|
+
event: "handoff-released",
|
|
744
|
+
reachAddress: target,
|
|
745
|
+
input: {
|
|
746
|
+
message_id: item.message_id,
|
|
747
|
+
target_message_id: item.handoff_message_id,
|
|
748
|
+
work: item.work,
|
|
749
|
+
source: item.recipient,
|
|
750
|
+
target
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
await appendFile(eventFile(options.profileDir ?? profileDir(), agent.harness, agent.sessionId), `${JSON.stringify(observation)}\n`, { mode: 384 });
|
|
754
|
+
}
|
|
755
|
+
const WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]|\p{Emoji_Presentation}/u;
|
|
756
|
+
const columns = (char) => WIDE.test(char) ? 2 : 1;
|
|
757
|
+
/** One terminal cell of exactly `width` columns: whitespace folded, cut with an ellipsis. */
|
|
758
|
+
function cell(value, width) {
|
|
759
|
+
const chars = [...value.replace(/\s+/g, " ").trim()];
|
|
760
|
+
const total = chars.reduce((sum, char) => sum + columns(char), 0);
|
|
761
|
+
let out = "";
|
|
762
|
+
let used = 0;
|
|
763
|
+
for (const char of chars) {
|
|
764
|
+
if (total > width && used + columns(char) > width - 1) {
|
|
765
|
+
out += "…";
|
|
766
|
+
used += 1;
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
out += char;
|
|
770
|
+
used += columns(char);
|
|
771
|
+
}
|
|
772
|
+
return out + " ".repeat(Math.max(0, width - used));
|
|
773
|
+
}
|
|
774
|
+
/** A prepared line cut at `width` columns, its alignment kept. */
|
|
775
|
+
function clip(value, width) {
|
|
776
|
+
let out = "";
|
|
777
|
+
let used = 0;
|
|
778
|
+
for (const char of value) {
|
|
779
|
+
if (used + columns(char) > width) break;
|
|
780
|
+
out += char;
|
|
781
|
+
used += columns(char);
|
|
782
|
+
}
|
|
783
|
+
return out;
|
|
784
|
+
}
|
|
785
|
+
/** Whitespace-folded text broken into lines of at most `width` columns. */
|
|
786
|
+
function wrap(value, width) {
|
|
787
|
+
const lines = [""];
|
|
788
|
+
let used = 0;
|
|
789
|
+
for (const char of value.replace(/\s+/g, " ").trim()) {
|
|
790
|
+
if (used + columns(char) > width) {
|
|
791
|
+
lines.push("");
|
|
792
|
+
used = 0;
|
|
793
|
+
}
|
|
794
|
+
lines[lines.length - 1] += char;
|
|
795
|
+
used += columns(char);
|
|
796
|
+
}
|
|
797
|
+
return lines;
|
|
798
|
+
}
|
|
799
|
+
function ago(seconds) {
|
|
800
|
+
if (seconds === "unknown") return "?";
|
|
801
|
+
const value = Math.round(seconds);
|
|
802
|
+
if (value < 60) return `${value}s`;
|
|
803
|
+
if (value < 3600) return `${Math.floor(value / 60)}m`;
|
|
804
|
+
return value < 86400 ? `${Math.floor(value / 3600)}h` : `${Math.floor(value / 86400)}d`;
|
|
805
|
+
}
|
|
806
|
+
function rowWho(row) {
|
|
807
|
+
const project = row.cwd === "unknown" ? "" : row.cwd.slice(row.cwd.lastIndexOf("/") + 1);
|
|
808
|
+
return `${row.harness} ${project || (row.reachAddress === "unknown" ? row.sessionId.slice(0, 8) : row.reachAddress)}`;
|
|
809
|
+
}
|
|
810
|
+
function rowWhat(row) {
|
|
811
|
+
const task = row.description === "unknown" ? "" : row.description;
|
|
812
|
+
const parts = (...items) => items.filter(Boolean).join(" · ");
|
|
813
|
+
if (row.group === "Needs you") return parts(row.waitingReason === "permission to approve" ? "approve a permission" : row.waitingReason, task);
|
|
814
|
+
if (row.group === "Working") return parts(row.subgroup === "working" ? "" : row.subgroup, task || "working");
|
|
815
|
+
if (row.group === "Finished") return parts(row.subgroup === "unknown" ? "finished" : row.subgroup, row.humanSeen === false ? "result unseen" : "", task);
|
|
816
|
+
return parts("state unknown", task);
|
|
817
|
+
}
|
|
818
|
+
/** The screen lists finished and unknown sessions active in the last day; --json and the snapshot list every session. */
|
|
819
|
+
function screenAgents(view) {
|
|
820
|
+
const agents = view.agents.filter((row) => !["Finished", "unknown"].includes(row.group) || typeof row.ageSeconds !== "number" || row.ageSeconds <= 86400);
|
|
821
|
+
return {
|
|
822
|
+
view: {
|
|
823
|
+
...view,
|
|
824
|
+
agents
|
|
825
|
+
},
|
|
826
|
+
hidden: view.agents.length - agents.length
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
/** The interactive squad screen: one line per agent, "Needs you" pinned on top, the selected row's details below. */
|
|
830
|
+
function renderAgentScreen(view, selected, size, message = "", hidden = 0) {
|
|
831
|
+
const width = Math.max(40, size.columns);
|
|
832
|
+
const whoWidth = Math.min(22, Math.floor(width / 4));
|
|
833
|
+
const whatWidth = width - whoWidth - 8;
|
|
834
|
+
const count = (group) => view.agents.filter((row) => row.group === group).length;
|
|
835
|
+
const header = [
|
|
836
|
+
`Sno Station · squad · ${new Date(view.observedAt).toTimeString().slice(0, 5)}`,
|
|
837
|
+
`${count("Needs you")} need you`,
|
|
838
|
+
`${count("Working")} working`,
|
|
839
|
+
`${count("Finished")} finished`,
|
|
840
|
+
...view.errors.length ? [`${view.errors.length} errors (sno --json squad)`] : []
|
|
841
|
+
];
|
|
842
|
+
const pinned = [];
|
|
843
|
+
const rest = [];
|
|
844
|
+
for (const [group, label] of [
|
|
845
|
+
["Needs you", "Needs you"],
|
|
846
|
+
["Working", "Working"],
|
|
847
|
+
["Finished", "Finished"],
|
|
848
|
+
["unknown", "Unknown"]
|
|
849
|
+
]) {
|
|
850
|
+
const members = view.agents.flatMap((row, index) => row.group === group ? [index] : []);
|
|
851
|
+
if (!members.length) continue;
|
|
852
|
+
const lines = [{
|
|
853
|
+
text: `${label} (${members.length})`,
|
|
854
|
+
index: -1
|
|
855
|
+
}, ...members.map((index) => {
|
|
856
|
+
const row = view.agents[index];
|
|
857
|
+
return {
|
|
858
|
+
index,
|
|
859
|
+
text: `${index === selected ? ">" : " "} ${cell(rowWho(row), whoWidth)} ${cell(rowWhat(row), whatWidth)} ${ago(row.ageSeconds).padStart(4)}`
|
|
860
|
+
};
|
|
861
|
+
})];
|
|
862
|
+
(group === "Needs you" ? pinned : rest).push(...lines);
|
|
863
|
+
}
|
|
864
|
+
if (hidden) rest.push({
|
|
865
|
+
text: ` ${hidden} older finished or unknown sessions hidden · sno --json squad lists all`,
|
|
866
|
+
index: -1
|
|
867
|
+
});
|
|
868
|
+
const row = view.agents[selected];
|
|
869
|
+
let details;
|
|
870
|
+
if (message) details = wrap(message, width).slice(0, Math.max(3, size.rows - 5));
|
|
871
|
+
else if (row) {
|
|
872
|
+
const handing = row.work.some((item) => item.handoff_to && !item.releasedAt) ? " (handing over)" : "";
|
|
873
|
+
const last = row.lastEvent === "unknown" ? "no event" : `last ${row.lastEvent.event} ${ago(row.ageSeconds)} ago`;
|
|
874
|
+
details = [
|
|
875
|
+
`Task: ${row.description}`,
|
|
876
|
+
`${row.cwd} · Reach ${row.reachAddress} · process ${row.process} · ${row.resumable} · session ${row.sessionId}`,
|
|
877
|
+
[
|
|
878
|
+
`Work: ${row.work.length} card(s)${handing}`,
|
|
879
|
+
last,
|
|
880
|
+
...row.missingReasons
|
|
881
|
+
].join(" · ")
|
|
882
|
+
];
|
|
883
|
+
} else details = [
|
|
884
|
+
"",
|
|
885
|
+
"",
|
|
886
|
+
""
|
|
887
|
+
];
|
|
888
|
+
const space = Math.max(1, size.rows - 3 - details.length);
|
|
889
|
+
const visible = (lines, room) => {
|
|
890
|
+
const at = lines.findIndex((line) => line.index === selected);
|
|
891
|
+
const start = lines.length <= room || at < 0 ? 0 : Math.min(Math.max(0, at - Math.floor(room / 2)), lines.length - room);
|
|
892
|
+
return lines.slice(start, start + room).map((line) => line.text);
|
|
893
|
+
};
|
|
894
|
+
const list = pinned.length < space ? [...pinned.map((line) => line.text), ...visible(rest, space - pinned.length)] : visible([...pinned, ...rest], space);
|
|
895
|
+
if (!view.agents.length) list.push("No observed agents.");
|
|
896
|
+
while (list.length < space) list.push("");
|
|
897
|
+
return [
|
|
898
|
+
cell(header.join(" · "), width),
|
|
899
|
+
...list.map((line) => clip(line, width)),
|
|
900
|
+
"─".repeat(width),
|
|
901
|
+
...details.map((line) => cell(line, width)),
|
|
902
|
+
cell("↑/↓ select · Enter result · Space refresh · r quota · q exit", width)
|
|
903
|
+
].map((line) => line.trimEnd()).join("\n");
|
|
904
|
+
}
|
|
905
|
+
function compactAgentView(view, current = {}) {
|
|
906
|
+
const rows = view.agents.filter((row) => !(current.harness === row.harness && current.sessionId === row.sessionId || current.reachAddress && current.reachAddress === row.reachAddress));
|
|
907
|
+
const waits = rows.filter((row) => row.waitingForHuman === "yes");
|
|
908
|
+
const working = rows.filter((row) => row.group === "Working").length;
|
|
909
|
+
const unknown = rows.filter((row) => row.waitingForHuman === "unknown" || row.group === "unknown").length + view.errors.length;
|
|
910
|
+
const reason = waits[0];
|
|
911
|
+
return `❄ ${reason ? `${waits.length} need you · ${reason.harness}: ${reason.waitingReason === "permission to approve" ? "approve" : reason.waitingReason}` : `${working} working`}${unknown ? ` · ${unknown} unknown` : ""}`;
|
|
912
|
+
}
|
|
913
|
+
async function markAgentResultSeen(row, profile = profileDir()) {
|
|
914
|
+
if (row.resultId === "unknown") return;
|
|
915
|
+
const target = eventFile(profile, row.harness, row.sessionId);
|
|
916
|
+
try {
|
|
917
|
+
await appendFile(target, `${JSON.stringify({
|
|
918
|
+
harness: row.harness,
|
|
919
|
+
sessionId: row.sessionId,
|
|
920
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
921
|
+
event: "human-seen",
|
|
922
|
+
input: { resultId: row.resultId }
|
|
923
|
+
})}\n`, { mode: 384 });
|
|
924
|
+
} catch (error) {
|
|
925
|
+
process.stderr.write(`${JSON.stringify(failure("append human result view", target, error, "result remains unseen"))}\n`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
export { readAgentView as a, screenAgents as c, foldActivity as d, isAgentText as f, observeAgentEvent as i, EMPTY_ACTIVITY_CURSOR as l, compactAgentView as n, recordAgentHandoff as o, readNewLines as p, markAgentResultSeen as r, renderAgentScreen as s, agentProcessIdentity as t, activityCursorSchema as u };
|