@ccmsg/cli 0.3.0 → 0.3.2
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 +2 -2
- package/src/instance/config.ts +18 -0
- package/src/instance/instance.ts +3 -0
- package/src/sessions/harness.ts +49 -21
- package/src/sessions/registry.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "kawaz",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "bun test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ccmsg/protocol": "1.
|
|
23
|
+
"@ccmsg/protocol": "1.10.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/instance/config.ts
CHANGED
|
@@ -286,6 +286,21 @@ function endpointOf(file: string, at: string, raw: unknown): Endpoint {
|
|
|
286
286
|
return raw as Endpoint;
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/** `terminal_gateway`'s shape, matched to the contract's `HelloResult` so a
|
|
290
|
+
* value this instance would refuse to report is refused here instead, at
|
|
291
|
+
* startup, rather than on the first `hello`. */
|
|
292
|
+
const TERMINAL_GATEWAY = /^https?:\/\/[^/?#\s]+(\/[^?#\s]*[^/?#\s])?$/;
|
|
293
|
+
|
|
294
|
+
function terminalGatewayOf(file: string, raw: string): string {
|
|
295
|
+
if (!TERMINAL_GATEWAY.test(raw)) {
|
|
296
|
+
throw new ConfigError(
|
|
297
|
+
file,
|
|
298
|
+
`upstream.terminal_gateway must be an http:// or https:// base URL with no trailing slash, got ${raw}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return raw;
|
|
302
|
+
}
|
|
303
|
+
|
|
289
304
|
function peersOf(file: string, raw: unknown): readonly Endpoint[] {
|
|
290
305
|
if (raw === undefined) return [];
|
|
291
306
|
if (!Array.isArray(raw)) throw new ConfigError(file, "peers must be an array of endpoint URLs");
|
|
@@ -334,6 +349,9 @@ function upstreamOf(file: string, raw: unknown): UpstreamConfig {
|
|
|
334
349
|
}
|
|
335
350
|
config[name] = value;
|
|
336
351
|
}
|
|
352
|
+
if (config["terminal_gateway"] !== undefined) {
|
|
353
|
+
config["terminal_gateway"] = terminalGatewayOf(file, config["terminal_gateway"]);
|
|
354
|
+
}
|
|
337
355
|
const launcher = fields["launcher"];
|
|
338
356
|
return {
|
|
339
357
|
...(config as UpstreamConfig),
|
package/src/instance/instance.ts
CHANGED
|
@@ -469,6 +469,9 @@ export class Instance {
|
|
|
469
469
|
transcript: this.#transcripts,
|
|
470
470
|
gateway: this.#gateway,
|
|
471
471
|
terminals: hostTerminalReader(),
|
|
472
|
+
...(config.upstream.terminal_gateway === undefined
|
|
473
|
+
? {}
|
|
474
|
+
: { terminalGateway: config.upstream.terminal_gateway }),
|
|
472
475
|
log: (msg, fields) => {
|
|
473
476
|
this.log.write(msg, fields);
|
|
474
477
|
},
|
package/src/sessions/harness.ts
CHANGED
|
@@ -127,6 +127,7 @@ class CodexThreads implements OwnSessions {
|
|
|
127
127
|
* so the watch is what the subscription drives, and no answer waits on it. */
|
|
128
128
|
export class HarnessSessions implements OwnSessions {
|
|
129
129
|
readonly #watch: DirectoryWatch;
|
|
130
|
+
readonly #lastComplete = new Map<string, AgentInfo>();
|
|
130
131
|
|
|
131
132
|
constructor(
|
|
132
133
|
private readonly dir: string,
|
|
@@ -174,16 +175,32 @@ export class HarnessSessions implements OwnSessions {
|
|
|
174
175
|
* uid's own config home (M6) — a syscall or two per session, not a wait. */
|
|
175
176
|
scan(): ReadonlyMap<Sid, AgentInfo> {
|
|
176
177
|
const rows = new Map<Sid, AgentInfo>();
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
const names = this.#watch.names().filter((name) => STATE_FILE.test(name));
|
|
179
|
+
const present = new Set(names);
|
|
180
|
+
for (const name of names) {
|
|
179
181
|
let document: unknown;
|
|
180
182
|
try {
|
|
181
183
|
document = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
182
184
|
} catch {
|
|
185
|
+
const previous = this.#lastComplete.get(name);
|
|
186
|
+
if (previous !== undefined) rows.set(previous.sid, previous);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const result = toRow(document, this.dir, this.instance);
|
|
190
|
+
if (!result.complete) {
|
|
191
|
+
const previous = this.#lastComplete.get(name);
|
|
192
|
+
if (previous !== undefined) rows.set(previous.sid, previous);
|
|
183
193
|
continue;
|
|
184
194
|
}
|
|
185
|
-
|
|
186
|
-
|
|
195
|
+
if (result.row === undefined) {
|
|
196
|
+
this.#lastComplete.delete(name);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
this.#lastComplete.set(name, result.row);
|
|
200
|
+
rows.set(result.row.sid, result.row);
|
|
201
|
+
}
|
|
202
|
+
for (const name of this.#lastComplete.keys()) {
|
|
203
|
+
if (!present.has(name)) this.#lastComplete.delete(name);
|
|
187
204
|
}
|
|
188
205
|
return rows;
|
|
189
206
|
}
|
|
@@ -248,6 +265,14 @@ export function isWaiting(row: AgentInfo): boolean {
|
|
|
248
265
|
return row.status === WAITING;
|
|
249
266
|
}
|
|
250
267
|
|
|
268
|
+
/** A syntactically complete state document, and the row it states when its
|
|
269
|
+
* process is still alive. A complete document for a dead process is distinct
|
|
270
|
+
* from a document caught between truncate and write: only the latter keeps the
|
|
271
|
+
* last complete row while the writer finishes. */
|
|
272
|
+
type RowResult =
|
|
273
|
+
| { readonly complete: false }
|
|
274
|
+
| { readonly complete: true; readonly row?: AgentInfo };
|
|
275
|
+
|
|
251
276
|
/** The conversion of one upstream document into the contract's spelling
|
|
252
277
|
* (§3.5): renamed to snake_case, instants in Unix ms, and nothing carried over
|
|
253
278
|
* that the contract does not name.
|
|
@@ -255,30 +280,33 @@ export function isWaiting(row: AgentInfo): boolean {
|
|
|
255
280
|
* A row whose process is gone is dropped: the file outlives a session that did
|
|
256
281
|
* not clean up after itself, and "the session exists" is what this input is
|
|
257
282
|
* for. */
|
|
258
|
-
function toRow(document: unknown, configDir: string, instance: InstanceId):
|
|
259
|
-
if (typeof document !== "object" || document === null) return
|
|
283
|
+
function toRow(document: unknown, configDir: string, instance: InstanceId): RowResult {
|
|
284
|
+
if (typeof document !== "object" || document === null) return { complete: false };
|
|
260
285
|
const raw = document as Record<string, unknown>;
|
|
261
286
|
const sid = text(raw["sessionId"]);
|
|
262
287
|
const pid = raw["pid"];
|
|
263
288
|
const cwd = text(raw["cwd"]);
|
|
264
289
|
const kind = text(raw["kind"]);
|
|
265
290
|
const startedAt = raw["startedAt"];
|
|
266
|
-
if (sid === undefined || cwd === undefined || kind === undefined) return
|
|
267
|
-
if (typeof pid !== "number" || typeof startedAt !== "number") return
|
|
268
|
-
if (!alive(pid)) return
|
|
291
|
+
if (sid === undefined || cwd === undefined || kind === undefined) return { complete: false };
|
|
292
|
+
if (typeof pid !== "number" || typeof startedAt !== "number") return { complete: false };
|
|
293
|
+
if (!alive(pid)) return { complete: true };
|
|
269
294
|
return {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
295
|
+
complete: true,
|
|
296
|
+
row: {
|
|
297
|
+
sid,
|
|
298
|
+
instance,
|
|
299
|
+
pid,
|
|
300
|
+
cwd,
|
|
301
|
+
kind,
|
|
302
|
+
started_at: startedAt,
|
|
303
|
+
config_dir: configDir,
|
|
304
|
+
...optional("name", text(raw["name"])),
|
|
305
|
+
...optional("status", text(raw["status"])),
|
|
306
|
+
...optional("waiting_for", text(raw["waitingFor"])),
|
|
307
|
+
...optional("state", text(raw["state"])),
|
|
308
|
+
...optional("background_id", text(raw["backgroundId"])),
|
|
309
|
+
},
|
|
282
310
|
};
|
|
283
311
|
}
|
|
284
312
|
|
package/src/sessions/registry.ts
CHANGED
|
@@ -98,6 +98,12 @@ export interface SessionsDeps {
|
|
|
98
98
|
* domain cannot judge: a peer's, whose claim is settled by an exchange of its
|
|
99
99
|
* own rather than by anything a session says (§7.2). */
|
|
100
100
|
readonly mesh?: MeshSource;
|
|
101
|
+
/** Where a person opens the terminal a session runs in, which `hello` states
|
|
102
|
+
* as `terminal_gateway`. The same value that gates the `terminal` capability
|
|
103
|
+
* (`sessionCapabilities`), so a client told the capability is on is told
|
|
104
|
+
* where to reach it in the same greeting. Absent on an instance with no
|
|
105
|
+
* gateway configured. */
|
|
106
|
+
readonly terminalGateway?: string;
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
/** What `hello` needs of the mesh: verify the greeting of a peer, and say which
|
|
@@ -291,6 +297,9 @@ export class Sessions implements UpstreamResource {
|
|
|
291
297
|
capabilities: [...this.deps.capabilities],
|
|
292
298
|
version: this.deps.version,
|
|
293
299
|
started_at: this.deps.startedAt,
|
|
300
|
+
...(this.deps.terminalGateway === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { terminal_gateway: this.deps.terminalGateway }),
|
|
294
303
|
...(expiresAt === undefined ? {} : { auth_expires_at: expiresAt }),
|
|
295
304
|
};
|
|
296
305
|
}
|