@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.3.0",
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.9.0"
23
+ "@ccmsg/protocol": "1.10.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
@@ -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),
@@ -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
  },
@@ -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
- for (const name of this.#watch.names()) {
178
- if (!STATE_FILE.test(name)) continue;
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
- const row = toRow(document, this.dir, this.instance);
186
- if (row !== undefined) rows.set(row.sid, row);
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): AgentInfo | undefined {
259
- if (typeof document !== "object" || document === null) return undefined;
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 undefined;
267
- if (typeof pid !== "number" || typeof startedAt !== "number") return undefined;
268
- if (!alive(pid)) return undefined;
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
- sid,
271
- instance,
272
- pid,
273
- cwd,
274
- kind,
275
- started_at: startedAt,
276
- config_dir: configDir,
277
- ...optional("name", text(raw["name"])),
278
- ...optional("status", text(raw["status"])),
279
- ...optional("waiting_for", text(raw["waitingFor"])),
280
- ...optional("state", text(raw["state"])),
281
- ...optional("background_id", text(raw["backgroundId"])),
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
 
@@ -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
  }