@ccmsg/cli 0.2.13 → 0.3.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/package.json +1 -1
- package/src/cli.ts +96 -29
- package/src/daemon/registry.ts +41 -14
- package/src/daemon/supervise.ts +2 -1
- package/src/harness/index.ts +96 -0
- package/src/instance/config.ts +18 -0
- package/src/instance/instance.ts +25 -5
- package/src/instance/paths.ts +32 -6
- package/src/messaging/direct.ts +116 -0
- package/src/plugin/claude.ts +1 -72
- package/src/plugin/codex.ts +332 -0
- package/src/plugin/index.ts +7 -5
- package/src/plugin/install.ts +41 -161
- package/src/plugin/receipt.ts +202 -0
- package/src/plugin/skill.ts +84 -0
- package/src/sessions/harness.ts +166 -31
- package/src/sessions/registry.ts +75 -53
- package/src/sessions/search.ts +4 -4
- package/src/transcript/files.ts +103 -22
- package/src/transcript/fold.ts +72 -1
package/src/sessions/harness.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FSWatcher, readdirSync, readFileSync, watch } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { AgentInfo, InstanceId, Sid } from "@ccmsg/protocol";
|
|
4
|
+
import type { Harness } from "../harness/index.ts";
|
|
4
5
|
|
|
5
6
|
/** The status the harness writes while a dialog is open and it is waiting for
|
|
6
7
|
* an answer, alongside a `waitingFor` naming what it waits on.
|
|
@@ -23,6 +24,96 @@ export const CONFIRM_POLL_MS = 5_000;
|
|
|
23
24
|
|
|
24
25
|
const STATE_FILE = /^\d+\.json$/;
|
|
25
26
|
|
|
27
|
+
/** Which sessions one harness says exist right now, read from its config home.
|
|
28
|
+
*
|
|
29
|
+
* Two answers rather than one, because the harnesses do not say the same
|
|
30
|
+
* amount. Claude Code writes a file per session carrying its pid, its working
|
|
31
|
+
* directory and what it is doing, which is the shape the contract's `AgentInfo`
|
|
32
|
+
* states and what the `agents` topic is; Codex says only that a thread has a
|
|
33
|
+
* live writer, which answers "is it there" and nothing else. So `rows` is what
|
|
34
|
+
* can be reported and `present` is what the classification reads, and a harness
|
|
35
|
+
* that reports nothing still has its sessions classified (§5.1). */
|
|
36
|
+
export interface OwnSessions {
|
|
37
|
+
readonly running: boolean;
|
|
38
|
+
/** Begins watching. Called when the first subscriber arrives and not before
|
|
39
|
+
* (§6.3 / §8.3: no upstream is read until somebody is listening). */
|
|
40
|
+
start(): void;
|
|
41
|
+
stop(): void;
|
|
42
|
+
/** The harness's own rows, as `agents` answers with them. Empty for a
|
|
43
|
+
* harness whose own view is not the one that contract states. */
|
|
44
|
+
rows(): ReadonlyMap<Sid, AgentInfo>;
|
|
45
|
+
/** The sessions the harness says are there at this instant. */
|
|
46
|
+
present(): ReadonlySet<Sid>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The one this config home runs (§3.8). */
|
|
50
|
+
export function ownSessions(
|
|
51
|
+
harness: Harness,
|
|
52
|
+
configHome: string,
|
|
53
|
+
instance: InstanceId,
|
|
54
|
+
onChange: () => void,
|
|
55
|
+
pollMs?: number,
|
|
56
|
+
): OwnSessions {
|
|
57
|
+
return harness === "codex"
|
|
58
|
+
? new CodexThreads(join(configHome, CODEX_LOCKS), onChange, pollMs)
|
|
59
|
+
: new HarnessSessions(join(configHome, "sessions"), instance, onChange, pollMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Where the Codex thread store takes a lock while a thread has a live writer,
|
|
63
|
+
* and what one of those locks is called.
|
|
64
|
+
*
|
|
65
|
+
* Measured against codex-cli 0.153.4: the file appears under this directory
|
|
66
|
+
* while a thread is being written and is gone once the process that had it
|
|
67
|
+
* ends normally. The `.coordination.lock` beside them belongs to the store's
|
|
68
|
+
* own cleanup and names no thread, which the shape below excludes.
|
|
69
|
+
*
|
|
70
|
+
* A process killed outright leaves its lock behind (measured), so a thread
|
|
71
|
+
* whose session died without a word reads as present until Codex itself sweeps
|
|
72
|
+
* the stale lock. That is the same direction as the state file Claude Code
|
|
73
|
+
* leaves behind — except that a lock names no pid, so there is nothing here to
|
|
74
|
+
* ask whether anybody still holds it. */
|
|
75
|
+
const CODEX_LOCKS = "thread-writer-locks";
|
|
76
|
+
const THREAD_LOCK = /^([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.lock$/;
|
|
77
|
+
|
|
78
|
+
/** The Codex threads with a live writer, read from one config home.
|
|
79
|
+
*
|
|
80
|
+
* It reports no rows: `AgentInfo` is Claude Code's own list (contract), and a
|
|
81
|
+
* lock file carries none of what that shape states. What a Codex session is —
|
|
82
|
+
* where it works, what it is called — is what it said when it greeted, and
|
|
83
|
+
* that is held by the registry for every harness alike. */
|
|
84
|
+
class CodexThreads implements OwnSessions {
|
|
85
|
+
readonly #watch: DirectoryWatch;
|
|
86
|
+
|
|
87
|
+
constructor(dir: string, onChange: () => void, pollMs?: number) {
|
|
88
|
+
this.#watch = new DirectoryWatch(dir, onChange, pollMs);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get running(): boolean {
|
|
92
|
+
return this.#watch.running;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
start(): void {
|
|
96
|
+
this.#watch.start();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
stop(): void {
|
|
100
|
+
this.#watch.stop();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
rows(): ReadonlyMap<Sid, AgentInfo> {
|
|
104
|
+
return new Map();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
present(): ReadonlySet<Sid> {
|
|
108
|
+
const live = new Set<Sid>();
|
|
109
|
+
for (const name of this.#watch.names()) {
|
|
110
|
+
const sid = THREAD_LOCK.exec(name)?.[1];
|
|
111
|
+
if (sid !== undefined) live.add(sid);
|
|
112
|
+
}
|
|
113
|
+
return live;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
26
117
|
/** The sessions the harness itself reports, read from one config home.
|
|
27
118
|
*
|
|
28
119
|
* The directory is the whole input: it says which sessions exist and which is
|
|
@@ -34,42 +125,38 @@ const STATE_FILE = /^\d+\.json$/;
|
|
|
34
125
|
* a question, and is done whenever one is asked. Watching it says the answer
|
|
35
126
|
* may have changed, which is only worth knowing while somebody is subscribed —
|
|
36
127
|
* so the watch is what the subscription drives, and no answer waits on it. */
|
|
37
|
-
export class HarnessSessions {
|
|
38
|
-
#
|
|
39
|
-
#timer: ReturnType<typeof setInterval> | undefined;
|
|
128
|
+
export class HarnessSessions implements OwnSessions {
|
|
129
|
+
readonly #watch: DirectoryWatch;
|
|
40
130
|
|
|
41
131
|
constructor(
|
|
42
132
|
private readonly dir: string,
|
|
43
133
|
private readonly instance: InstanceId,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
) {
|
|
134
|
+
onChange: () => void,
|
|
135
|
+
pollMs?: number,
|
|
136
|
+
) {
|
|
137
|
+
this.#watch = new DirectoryWatch(dir, onChange, pollMs);
|
|
138
|
+
}
|
|
47
139
|
|
|
48
140
|
get running(): boolean {
|
|
49
|
-
return this.#
|
|
141
|
+
return this.#watch.running;
|
|
50
142
|
}
|
|
51
143
|
|
|
52
|
-
/** Begins watching. Called when the first subscriber arrives and not before
|
|
53
|
-
* (§6.3 / §8.3: no upstream is read until somebody is listening). */
|
|
54
144
|
start(): void {
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
this.#watcher = watch(this.dir, this.onChange);
|
|
58
|
-
} catch {
|
|
59
|
-
// The directory does not exist yet — a config home whose harness has not
|
|
60
|
-
// run. The poll below both covers the wait and picks it up when it
|
|
61
|
-
// appears, so this is not a failure to start.
|
|
62
|
-
this.#watcher = undefined;
|
|
63
|
-
}
|
|
64
|
-
this.#timer = setInterval(this.onChange, this.pollMs);
|
|
65
|
-
this.onChange();
|
|
145
|
+
this.#watch.start();
|
|
66
146
|
}
|
|
67
147
|
|
|
68
148
|
stop(): void {
|
|
69
|
-
this.#
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
149
|
+
this.#watch.stop();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
rows(): ReadonlyMap<Sid, AgentInfo> {
|
|
153
|
+
return this.scan();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Every session with a state file, which for this harness is the same
|
|
157
|
+
* reading its rows came from. */
|
|
158
|
+
present(): ReadonlySet<Sid> {
|
|
159
|
+
return new Set(this.scan().keys());
|
|
73
160
|
}
|
|
74
161
|
|
|
75
162
|
/** The directory as it is at this instant.
|
|
@@ -87,13 +174,7 @@ export class HarnessSessions {
|
|
|
87
174
|
* uid's own config home (M6) — a syscall or two per session, not a wait. */
|
|
88
175
|
scan(): ReadonlyMap<Sid, AgentInfo> {
|
|
89
176
|
const rows = new Map<Sid, AgentInfo>();
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
names = readdirSync(this.dir);
|
|
93
|
-
} catch {
|
|
94
|
-
return rows;
|
|
95
|
-
}
|
|
96
|
-
for (const name of names) {
|
|
177
|
+
for (const name of this.#watch.names()) {
|
|
97
178
|
if (!STATE_FILE.test(name)) continue;
|
|
98
179
|
let document: unknown;
|
|
99
180
|
try {
|
|
@@ -108,6 +189,60 @@ export class HarnessSessions {
|
|
|
108
189
|
}
|
|
109
190
|
}
|
|
110
191
|
|
|
192
|
+
/** One directory that says what the harness's sessions are, watched while
|
|
193
|
+
* somebody is subscribed and read whenever an answer is wanted.
|
|
194
|
+
*
|
|
195
|
+
* The two things §6.3 separates live here. Reading the directory answers a
|
|
196
|
+
* question, and is done whenever one is asked. Watching it says the answer may
|
|
197
|
+
* have changed, which is only worth knowing while somebody is listening — so
|
|
198
|
+
* the watch is what the subscription drives, and no answer waits on it. */
|
|
199
|
+
class DirectoryWatch {
|
|
200
|
+
#watcher: FSWatcher | undefined;
|
|
201
|
+
#timer: ReturnType<typeof setInterval> | undefined;
|
|
202
|
+
|
|
203
|
+
constructor(
|
|
204
|
+
private readonly dir: string,
|
|
205
|
+
private readonly onChange: () => void,
|
|
206
|
+
private readonly pollMs: number = CONFIRM_POLL_MS,
|
|
207
|
+
) {}
|
|
208
|
+
|
|
209
|
+
get running(): boolean {
|
|
210
|
+
return this.#watcher !== undefined || this.#timer !== undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
start(): void {
|
|
214
|
+
if (this.running) return;
|
|
215
|
+
try {
|
|
216
|
+
this.#watcher = watch(this.dir, this.onChange);
|
|
217
|
+
} catch {
|
|
218
|
+
// The directory does not exist yet — a config home whose harness has not
|
|
219
|
+
// run. The poll below both covers the wait and picks it up when it
|
|
220
|
+
// appears, so this is not a failure to start.
|
|
221
|
+
this.#watcher = undefined;
|
|
222
|
+
}
|
|
223
|
+
this.#timer = setInterval(this.onChange, this.pollMs);
|
|
224
|
+
this.onChange();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
stop(): void {
|
|
228
|
+
this.#watcher?.close();
|
|
229
|
+
this.#watcher = undefined;
|
|
230
|
+
if (this.#timer !== undefined) clearInterval(this.#timer);
|
|
231
|
+
this.#timer = undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** What is in the directory now. Read in place because it is a handful of
|
|
235
|
+
* small entries of this uid's own config home (M6) — a syscall or two, not a
|
|
236
|
+
* wait. */
|
|
237
|
+
names(): string[] {
|
|
238
|
+
try {
|
|
239
|
+
return readdirSync(this.dir);
|
|
240
|
+
} catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
111
246
|
/** Whether the harness says this session is waiting on a dialog. */
|
|
112
247
|
export function isWaiting(row: AgentInfo): boolean {
|
|
113
248
|
return row.status === WAITING;
|
package/src/sessions/registry.ts
CHANGED
|
@@ -19,16 +19,32 @@ import {
|
|
|
19
19
|
} from "@ccmsg/protocol";
|
|
20
20
|
import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
|
|
21
21
|
import { within } from "../files/index.ts";
|
|
22
|
+
import { HARNESS, type Harness } from "../harness/index.ts";
|
|
22
23
|
import type { TranscriptFacts } from "../transcript/index.ts";
|
|
23
24
|
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
24
25
|
import { classify, type SessionInputs } from "./classify.ts";
|
|
25
|
-
import {
|
|
26
|
+
import { isWaiting, type OwnSessions, ownSessions } from "./harness.ts";
|
|
26
27
|
import { LastLiveStore, type StoredEntry } from "./last-live.ts";
|
|
27
28
|
import { stoppedOn } from "./status.ts";
|
|
28
29
|
import { TerminalCache, type TerminalReader } from "./terminals.ts";
|
|
29
30
|
|
|
31
|
+
/** What the harness says at one instant: the rows it reports, and which
|
|
32
|
+
* sessions it says are there (§3.8).
|
|
33
|
+
*
|
|
34
|
+
* Two readings of one moment, passed together so a caller answering several
|
|
35
|
+
* questions about that moment reads once. They are the same set for a harness
|
|
36
|
+
* that reports a row per session and differ for one that reports none, which
|
|
37
|
+
* is why the classification reads `present` and never the rows' keys. */
|
|
38
|
+
interface Own {
|
|
39
|
+
readonly rows: ReadonlyMap<Sid, AgentInfo>;
|
|
40
|
+
readonly present: ReadonlySet<Sid>;
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
/** What the sessions domain needs from the instance around it. */
|
|
31
44
|
export interface SessionsDeps {
|
|
45
|
+
/** Which harness this config home runs (§3.8). It decides what says a
|
|
46
|
+
* session is there and, through that, what `agents` can report. */
|
|
47
|
+
readonly harness: Harness;
|
|
32
48
|
readonly self: InstanceId;
|
|
33
49
|
/** Where this instance says it is reached, which `hello` states beside the
|
|
34
50
|
* id: the caller got here by some URL of its own — a proxy's, an alias — and
|
|
@@ -155,7 +171,7 @@ interface Connected {
|
|
|
155
171
|
* built, and never stored (M4). */
|
|
156
172
|
export class Sessions implements UpstreamResource {
|
|
157
173
|
readonly #connected = new Map<Sid, Connected>();
|
|
158
|
-
readonly #harness:
|
|
174
|
+
readonly #harness: OwnSessions;
|
|
159
175
|
readonly #terminals: TerminalCache | undefined;
|
|
160
176
|
readonly #lastLive: LastLiveStore;
|
|
161
177
|
/** Sessions seen live since the last recompute, kept so the moment one stops
|
|
@@ -191,8 +207,9 @@ export class Sessions implements UpstreamResource {
|
|
|
191
207
|
readonly #stopping = new Map<Sid, Timestamp>();
|
|
192
208
|
|
|
193
209
|
constructor(private readonly deps: SessionsDeps) {
|
|
194
|
-
this.#harness =
|
|
195
|
-
|
|
210
|
+
this.#harness = ownSessions(
|
|
211
|
+
deps.harness,
|
|
212
|
+
deps.configHome,
|
|
196
213
|
deps.self,
|
|
197
214
|
() => this.changed(),
|
|
198
215
|
deps.pollMs,
|
|
@@ -203,7 +220,7 @@ export class Sessions implements UpstreamResource {
|
|
|
203
220
|
deps.terminals === undefined
|
|
204
221
|
? undefined
|
|
205
222
|
: new TerminalCache(deps.terminals, () => this.changed());
|
|
206
|
-
this.#live = this.#liveNow(Date.now(), this.#
|
|
223
|
+
this.#live = this.#liveNow(Date.now(), this.#own());
|
|
207
224
|
}
|
|
208
225
|
|
|
209
226
|
/** `hello`, which is where a session becomes something this instance can
|
|
@@ -283,18 +300,18 @@ export class Sessions implements UpstreamResource {
|
|
|
283
300
|
classify(
|
|
284
301
|
sid: Sid,
|
|
285
302
|
now: Timestamp = Date.now(),
|
|
286
|
-
|
|
303
|
+
own: Own = this.#own(),
|
|
287
304
|
): SessionState | undefined {
|
|
288
|
-
return classify(this.inputs(sid,
|
|
305
|
+
return classify(this.inputs(sid, own), now);
|
|
289
306
|
}
|
|
290
307
|
|
|
291
308
|
/** The harness's sessions as they are at this instant. One read serves one
|
|
292
309
|
* question, and a caller answering several about the same instant passes the
|
|
293
310
|
* result on rather than reading again. */
|
|
294
|
-
#
|
|
295
|
-
const rows = this.#harness.
|
|
311
|
+
#own(): Own {
|
|
312
|
+
const rows = this.#harness.rows();
|
|
296
313
|
const terminals = this.#terminals;
|
|
297
|
-
if (terminals === undefined) return rows;
|
|
314
|
+
if (terminals === undefined) return { rows, present: this.#harness.present() };
|
|
298
315
|
// What the scan found is what exists: a pid that has left it is one whose
|
|
299
316
|
// terminal is no longer anybody's, and one that has arrived is read once.
|
|
300
317
|
terminals.observe([...rows.values()].map((row) => row.pid));
|
|
@@ -314,7 +331,7 @@ export class Sessions implements UpstreamResource {
|
|
|
314
331
|
},
|
|
315
332
|
);
|
|
316
333
|
}
|
|
317
|
-
return named;
|
|
334
|
+
return { rows: named, present: this.#harness.present() };
|
|
318
335
|
}
|
|
319
336
|
|
|
320
337
|
/** Everything the classification of one session reads, exposed so the rule
|
|
@@ -327,21 +344,22 @@ export class Sessions implements UpstreamResource {
|
|
|
327
344
|
* "a session exists" mean "somebody is listening", which is how a live
|
|
328
345
|
* session becomes `session_not_found` to a sender and how a session that is
|
|
329
346
|
* still running is written into `last_live` as gone. */
|
|
330
|
-
inputs(sid: Sid,
|
|
331
|
-
const row = rows.get(sid);
|
|
347
|
+
inputs(sid: Sid, own: Own = this.#own()): SessionInputs {
|
|
348
|
+
const row = own.rows.get(sid);
|
|
349
|
+
const present = own.present.has(sid);
|
|
332
350
|
const stored = this.#lastLive.get(sid);
|
|
333
351
|
const facts = this.deps.transcript?.facts(sid);
|
|
334
|
-
const gatewayActiveAt = this.#gatewayActiveAt(sid,
|
|
352
|
+
const gatewayActiveAt = this.#gatewayActiveAt(sid, present);
|
|
335
353
|
return {
|
|
336
354
|
connected: this.#connected.has(sid),
|
|
337
355
|
...(gatewayActiveAt === undefined ? {} : { gateway_active_at: gatewayActiveAt }),
|
|
338
356
|
...(facts === undefined || stoppedOn(facts) === undefined ? {} : { api_error_stopped: true }),
|
|
339
|
-
...(
|
|
357
|
+
...(!present
|
|
340
358
|
? {}
|
|
341
359
|
: {
|
|
342
360
|
harness: {
|
|
343
|
-
waiting: isWaiting(row),
|
|
344
|
-
...(row
|
|
361
|
+
waiting: row !== undefined && isWaiting(row),
|
|
362
|
+
...(row?.terminal_id === undefined ? {} : { terminal_id: row.terminal_id }),
|
|
345
363
|
},
|
|
346
364
|
}),
|
|
347
365
|
...(stored === undefined ? {} : { last_live: { stopped_at: stored.stopped_at } }),
|
|
@@ -379,7 +397,7 @@ export class Sessions implements UpstreamResource {
|
|
|
379
397
|
* so a session that named neither is one no path is admitted for. */
|
|
380
398
|
where(sid: Sid): { root?: string; cwd?: string } {
|
|
381
399
|
const meta = this.#connected.get(sid)?.meta;
|
|
382
|
-
const cwd = meta?.cwd ?? this.#
|
|
400
|
+
const cwd = meta?.cwd ?? this.#own().rows.get(sid)?.cwd;
|
|
383
401
|
// The container when the session named one, the working directory
|
|
384
402
|
// otherwise — the same order `repo_root` is meant in (§4.2).
|
|
385
403
|
const root = meta?.repo_root ?? cwd;
|
|
@@ -394,7 +412,7 @@ export class Sessions implements UpstreamResource {
|
|
|
394
412
|
* through this: the watch runs only while somebody is subscribed (§6.3), and
|
|
395
413
|
* a pid from a poll that has not run is a number belonging to nobody. */
|
|
396
414
|
rowsNow(): ReadonlyMap<Sid, AgentInfo> {
|
|
397
|
-
return this.#
|
|
415
|
+
return this.#own().rows;
|
|
398
416
|
}
|
|
399
417
|
|
|
400
418
|
/** Drop one entry from `last_live`, which is what
|
|
@@ -445,8 +463,8 @@ export class Sessions implements UpstreamResource {
|
|
|
445
463
|
}
|
|
446
464
|
|
|
447
465
|
snapshot(topic: string): readonly TopicValue[] {
|
|
448
|
-
const
|
|
449
|
-
const data = topic === "agents" ? this.agents(
|
|
466
|
+
const own = this.#own();
|
|
467
|
+
const data = topic === "agents" ? this.agents(own) : this.peers(Date.now(), own);
|
|
450
468
|
return [{ instance: this.deps.self, data }];
|
|
451
469
|
}
|
|
452
470
|
|
|
@@ -473,14 +491,14 @@ export class Sessions implements UpstreamResource {
|
|
|
473
491
|
* from stating that nothing is reachable. */
|
|
474
492
|
peers(
|
|
475
493
|
now: Timestamp = Date.now(),
|
|
476
|
-
|
|
494
|
+
own: Own = this.#own(),
|
|
477
495
|
): { peers: PeerInfo[]; last_live: LastLiveSession[]; instances?: InstanceInfo[] } {
|
|
478
496
|
const instances = this.deps.mesh?.instances();
|
|
479
497
|
return {
|
|
480
|
-
peers: [...this.#connected.values()].map((session) => this.#peer(session, now,
|
|
498
|
+
peers: [...this.#connected.values()].map((session) => this.#peer(session, now, own)),
|
|
481
499
|
last_live: this.#lastLive.entries(now).map((entry) => ({
|
|
482
500
|
...entry,
|
|
483
|
-
state: this.classify(entry.sid, now,
|
|
501
|
+
state: this.classify(entry.sid, now, own) ?? "disappeared",
|
|
484
502
|
pinned: this.#pinned(entry.sid),
|
|
485
503
|
})),
|
|
486
504
|
...(instances === undefined ? {} : { instances }),
|
|
@@ -493,8 +511,8 @@ export class Sessions implements UpstreamResource {
|
|
|
493
511
|
* make every confirmation poll a value the list did not have before, so the
|
|
494
512
|
* one suppression every topic shares (M5) would let a five-second heartbeat
|
|
495
513
|
* through for a directory that had not changed. */
|
|
496
|
-
agents(
|
|
497
|
-
return { agents: [...rows.values()] };
|
|
514
|
+
agents(own: Own = this.#own()): { agents: AgentInfo[] } {
|
|
515
|
+
return { agents: [...own.rows.values()] };
|
|
498
516
|
}
|
|
499
517
|
|
|
500
518
|
/** Bind a session to this instance, and take what it says about itself. Its
|
|
@@ -515,7 +533,7 @@ export class Sessions implements UpstreamResource {
|
|
|
515
533
|
const held = this.#connected.get(sid);
|
|
516
534
|
const meta = {
|
|
517
535
|
...this.#stated.get(sid),
|
|
518
|
-
...metaOf(
|
|
536
|
+
...metaOf(this.deps, args, (refused) => {
|
|
519
537
|
this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
|
|
520
538
|
}),
|
|
521
539
|
};
|
|
@@ -552,8 +570,8 @@ export class Sessions implements UpstreamResource {
|
|
|
552
570
|
* mechanism and is written once for every topic (M5) — a payload equal to
|
|
553
571
|
* the last one goes no further than that. */
|
|
554
572
|
private changed(now: Timestamp = Date.now()): void {
|
|
555
|
-
const
|
|
556
|
-
const live = this.#liveNow(now,
|
|
573
|
+
const own = this.#own();
|
|
574
|
+
const live = this.#liveNow(now, own);
|
|
557
575
|
for (const [sid, entry] of this.#live) {
|
|
558
576
|
if (live.has(sid)) continue;
|
|
559
577
|
// The declaration came first and the departure has now arrived, which is
|
|
@@ -571,23 +589,23 @@ export class Sessions implements UpstreamResource {
|
|
|
571
589
|
this.#stated.delete(sid);
|
|
572
590
|
}
|
|
573
591
|
this.#live = live;
|
|
574
|
-
this.deps.publish("peers", this.peers(now,
|
|
575
|
-
this.deps.publish("agents", this.agents(
|
|
592
|
+
this.deps.publish("peers", this.peers(now, own));
|
|
593
|
+
this.deps.publish("agents", this.agents(own));
|
|
576
594
|
this.deps.onChanged?.();
|
|
577
595
|
}
|
|
578
596
|
|
|
579
597
|
/** Every session live right now, in the form its `last_live` entry takes if
|
|
580
598
|
* it stops being live. */
|
|
581
|
-
#liveNow(now: Timestamp,
|
|
599
|
+
#liveNow(now: Timestamp, own: Own): Map<Sid, StoredEntry> {
|
|
582
600
|
const live = new Map<Sid, StoredEntry>();
|
|
583
|
-
for (const sid of this.#connected.keys()) live.set(sid, this.#entry(sid, now,
|
|
584
|
-
for (const sid of
|
|
601
|
+
for (const sid of this.#connected.keys()) live.set(sid, this.#entry(sid, now, own));
|
|
602
|
+
for (const sid of own.present) live.set(sid, this.#entry(sid, now, own));
|
|
585
603
|
return live;
|
|
586
604
|
}
|
|
587
605
|
|
|
588
|
-
#entry(sid: Sid, now: Timestamp,
|
|
606
|
+
#entry(sid: Sid, now: Timestamp, own: Own): StoredEntry {
|
|
589
607
|
const held = this.#connected.get(sid);
|
|
590
|
-
const row = rows.get(sid);
|
|
608
|
+
const row = own.rows.get(sid);
|
|
591
609
|
// What answered last, not what the session named when it greeted: the
|
|
592
610
|
// greeting is one instant and `/model` moves afterwards, so the fold is
|
|
593
611
|
// asked first and the greeting only fills in for a transcript that has
|
|
@@ -602,7 +620,7 @@ export class Sessions implements UpstreamResource {
|
|
|
602
620
|
// The harness knows a title for a session that stated none itself, so
|
|
603
621
|
// it goes first and what the session named overrides it.
|
|
604
622
|
...(row?.name === undefined ? {} : { title: row.name }),
|
|
605
|
-
...this.#where(sid,
|
|
623
|
+
...this.#where(sid, own),
|
|
606
624
|
...(model === undefined ? {} : { model }),
|
|
607
625
|
...(effort === undefined ? {} : { effort }),
|
|
608
626
|
...(held === undefined ? {} : { connected_at: held.connected_at }),
|
|
@@ -610,7 +628,7 @@ export class Sessions implements UpstreamResource {
|
|
|
610
628
|
};
|
|
611
629
|
}
|
|
612
630
|
|
|
613
|
-
#peer(session: Connected, now: Timestamp,
|
|
631
|
+
#peer(session: Connected, now: Timestamp, own: Own): PeerInfo {
|
|
614
632
|
// The two "last activity" values are different questions (§5.3): the one
|
|
615
633
|
// above moves on every request the session makes, this one only when a
|
|
616
634
|
// person speaks, and the fold is the only place that knows the second.
|
|
@@ -618,12 +636,12 @@ export class Sessions implements UpstreamResource {
|
|
|
618
636
|
// What the gateway last saw run for this session: an attribute of the row
|
|
619
637
|
// beside the classification, not folded into it (§5.1). Absent from an
|
|
620
638
|
// instance with no gateway, where nothing observes inference at all.
|
|
621
|
-
const gatewayActiveAt = this.#gatewayActiveAt(session.sid,
|
|
639
|
+
const gatewayActiveAt = this.#gatewayActiveAt(session.sid, own.present.has(session.sid));
|
|
622
640
|
return {
|
|
623
641
|
sid: session.sid,
|
|
624
642
|
instance: this.deps.self,
|
|
625
|
-
...this.#where(session.sid,
|
|
626
|
-
state: this.classify(session.sid, now,
|
|
643
|
+
...this.#where(session.sid, own),
|
|
644
|
+
state: this.classify(session.sid, now, own) ?? "live",
|
|
627
645
|
pinned: this.#pinned(session.sid),
|
|
628
646
|
connected_at: session.connected_at,
|
|
629
647
|
last_activity_at: session.last_activity_at,
|
|
@@ -675,10 +693,10 @@ export class Sessions implements UpstreamResource {
|
|
|
675
693
|
* until one does. */
|
|
676
694
|
#where(
|
|
677
695
|
sid: Sid,
|
|
678
|
-
|
|
696
|
+
own: Own,
|
|
679
697
|
): Pick<PeerInfo, "repo" | "ws" | "cwd" | "transcript_path" | "repo_root" | "branch" | "title"> {
|
|
680
698
|
const meta = this.#stated.get(sid) ?? {};
|
|
681
|
-
const cwd = meta.cwd ?? rows.get(sid)?.cwd ?? "";
|
|
699
|
+
const cwd = meta.cwd ?? own.rows.get(sid)?.cwd ?? "";
|
|
682
700
|
return {
|
|
683
701
|
repo: meta.repo ?? "",
|
|
684
702
|
ws: meta.ws ?? "",
|
|
@@ -697,7 +715,7 @@ export class Sessions implements UpstreamResource {
|
|
|
697
715
|
*
|
|
698
716
|
* `transcript_path` is the exception, because it is the one field that is not
|
|
699
717
|
* only displayed: it names a file this instance then reads and follows. What is
|
|
700
|
-
* taken is a path under this config home's
|
|
718
|
+
* taken is a path under this config home's transcript tree, resolved, and nothing
|
|
701
719
|
* else — a session naming a file elsewhere is a session that named nothing,
|
|
702
720
|
* which is what a session that stayed silent already is (M6). It is not an
|
|
703
721
|
* error: how a session describes itself is its own business, and the instance
|
|
@@ -705,8 +723,8 @@ export class Sessions implements UpstreamResource {
|
|
|
705
723
|
* not taken is told to `refused`, which is the operator's answer to a field
|
|
706
724
|
* that is simply absent from what `peers` says. */
|
|
707
725
|
function metaOf(
|
|
726
|
+
deps: Pick<SessionsDeps, "configHome" | "harness">,
|
|
708
727
|
args: HelloArgs,
|
|
709
|
-
configHome: string,
|
|
710
728
|
refused: (reason: string) => void,
|
|
711
729
|
): SessionMeta {
|
|
712
730
|
const meta: Record<string, string> = {};
|
|
@@ -714,7 +732,7 @@ function metaOf(
|
|
|
714
732
|
const value = args[field];
|
|
715
733
|
if (value === undefined) continue;
|
|
716
734
|
if (field === "transcript_path") {
|
|
717
|
-
const taken = ownTranscript(value,
|
|
735
|
+
const taken = ownTranscript(value, deps);
|
|
718
736
|
if (typeof taken === "string") meta[field] = taken;
|
|
719
737
|
else refused(taken.refused);
|
|
720
738
|
continue;
|
|
@@ -727,7 +745,7 @@ function metaOf(
|
|
|
727
745
|
/** A transcript path this instance will read, or nothing.
|
|
728
746
|
|
|
729
747
|
* The test is where the file would be, not whether it is there. M6 is a
|
|
730
|
-
* boundary on what this instance reads, and a path inside
|
|
748
|
+
* boundary on what this instance reads, and a path inside the tree stays
|
|
731
749
|
* inside it whether or not anything has been written there yet — a session
|
|
732
750
|
* greeting at its very start names a transcript the harness has created
|
|
733
751
|
* neither the file nor the directory for, and refusing it would mean the one
|
|
@@ -741,23 +759,27 @@ function metaOf(
|
|
|
741
759
|
* along it that leads out of the tree lands outside and is refused. What is
|
|
742
760
|
* already there must be a file: a directory by that name is not a transcript.
|
|
743
761
|
*
|
|
744
|
-
*
|
|
762
|
+
* The tree itself is settled the same way, so a config home whose first
|
|
745
763
|
* session has yet to write anything is a boundary all the same: the directory
|
|
746
764
|
* that is there is followed, the part that is not is taken as spelled, and the
|
|
747
765
|
* comparison is between two paths resolved by one rule. The config home is not
|
|
748
766
|
* treated that way — an instance answers for a home it is running out of, and
|
|
749
767
|
* one that is not there names no tree to be inside of. */
|
|
750
|
-
function ownTranscript(
|
|
768
|
+
function ownTranscript(
|
|
769
|
+
named: string,
|
|
770
|
+
deps: Pick<SessionsDeps, "configHome" | "harness">,
|
|
771
|
+
): string | Refused {
|
|
751
772
|
if (!isAbsolute(named)) return { refused: "not an absolute path" };
|
|
752
|
-
let
|
|
773
|
+
let tree: string | undefined;
|
|
753
774
|
try {
|
|
754
|
-
|
|
775
|
+
const home = realpathSync(deps.configHome);
|
|
776
|
+
tree = resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
|
|
755
777
|
} catch {
|
|
756
778
|
return { refused: "the config home is not there" };
|
|
757
779
|
}
|
|
758
780
|
const settled = resolveAsFarAsItGoes(named);
|
|
759
|
-
if (
|
|
760
|
-
return { refused: "outside this config home's
|
|
781
|
+
if (tree === undefined || settled === undefined || !within(settled, tree)) {
|
|
782
|
+
return { refused: "outside this config home's transcript tree" };
|
|
761
783
|
}
|
|
762
784
|
const stat = statSync(settled, { throwIfNoEntry: false });
|
|
763
785
|
if (stat !== undefined && !stat.isFile()) return { refused: "not a file" };
|
package/src/sessions/search.ts
CHANGED
|
@@ -239,11 +239,11 @@ function says(text: string, clauses: readonly Clause[]): boolean {
|
|
|
239
239
|
|
|
240
240
|
/** Whether the project directory could be the working directory asked for.
|
|
241
241
|
*
|
|
242
|
-
*
|
|
243
|
-
* is lossy — separators, dots and underscores all become dashes — so this only
|
|
242
|
+
* A harness that files by working directory flattens it into one name, and the
|
|
243
|
+
* flattening is lossy — separators, dots and underscores all become dashes — so this only
|
|
244
244
|
* narrows what is opened. What decides a hit is the transcript's own `cwd`. */
|
|
245
|
-
function looksLike(project: string, words: readonly string[]): boolean {
|
|
246
|
-
if (words.length === 0) return true;
|
|
245
|
+
function looksLike(project: string | undefined, words: readonly string[]): boolean {
|
|
246
|
+
if (words.length === 0 || project === undefined) return true;
|
|
247
247
|
const flat = flatten(project);
|
|
248
248
|
return words.every((word) => flat.includes(flatten(word)));
|
|
249
249
|
}
|