@kontextmind/kxm 0.7.70 → 0.7.72
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +22 -0
- package/docs/operations.md +37 -5
- package/docs/test-matrix.md +1 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +83 -19
- package/plugins/kxm/dist/client.js +50 -3
- package/plugins/kxm/dist/core.js +31 -4
- package/plugins/kxm/dist/extension.js +59 -7
- package/plugins/kxm/dist/mcp-server.js +60 -8
- package/plugins/kxm/dist/runtime.js +4 -2
- package/plugins/kxm/dist/server.js +317 -20
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/references/protocol.md +18 -0
- package/plugins/kxm/skills/kxm-peer/SKILL.md +3 -1
- package/plugins/kxm/src/cli.ts +2 -1
- package/plugins/kxm/src/client.ts +62 -3
- package/plugins/kxm/src/commands.ts +10 -4
- package/plugins/kxm/src/database.ts +6 -2
- package/plugins/kxm/src/external-effects.ts +390 -66
- package/plugins/kxm/src/hub.ts +171 -12
- package/plugins/kxm/src/local-snapshot.ts +10 -2
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/protocol.ts +67 -1
- package/plugins/kxm/src/store.ts +217 -6
- package/plugins/kxm/src/tui.ts +20 -4
package/plugins/kxm/src/store.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import type { DatabaseSync } from "./sqlite.ts";
|
|
3
|
-
import type {
|
|
3
|
+
import type { AgentIdentity, LeaseRecord, MessageRecord } from "./protocol.ts";
|
|
4
4
|
import type { ContextItem } from "./context.ts";
|
|
5
5
|
import type { WorkflowJournalEntry, WorkflowRun } from "./workflow.ts";
|
|
6
6
|
import {
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type DatabaseSchemaSpec,
|
|
10
10
|
} from "./database.ts";
|
|
11
11
|
|
|
12
|
-
export const HUB_STORE_SCHEMA_VERSION =
|
|
12
|
+
export const HUB_STORE_SCHEMA_VERSION = 4;
|
|
13
13
|
|
|
14
14
|
export const HUB_STORE_TABLES: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
15
15
|
agents: Object.freeze(["id", "record"]),
|
|
@@ -19,9 +19,10 @@ export const HUB_STORE_TABLES: Readonly<Record<string, readonly string[]>> = Obj
|
|
|
19
19
|
workflow_runs: Object.freeze(["id", "definition_id", "delivery_id", "record"]),
|
|
20
20
|
workflow_journal: Object.freeze(["id", "run_id", "category", "area", "record"]),
|
|
21
21
|
context_items: Object.freeze(["id", "project", "kind", "record"]),
|
|
22
|
+
leases: Object.freeze(["resource", "holder_agent_id", "fencing_token", "expires_at", "record"]),
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
export const
|
|
25
|
+
export const HUB_STORE_SCHEMA_V4 = `
|
|
25
26
|
CREATE TABLE IF NOT EXISTS agents (
|
|
26
27
|
id TEXT PRIMARY KEY,
|
|
27
28
|
record TEXT NOT NULL
|
|
@@ -70,18 +71,62 @@ export const HUB_STORE_SCHEMA_V3 = `
|
|
|
70
71
|
record TEXT NOT NULL
|
|
71
72
|
) STRICT;
|
|
72
73
|
CREATE INDEX IF NOT EXISTS context_items_project ON context_items(project);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS leases (
|
|
75
|
+
resource TEXT PRIMARY KEY,
|
|
76
|
+
holder_agent_id TEXT NOT NULL,
|
|
77
|
+
fencing_token INTEGER NOT NULL,
|
|
78
|
+
expires_at TEXT NOT NULL,
|
|
79
|
+
record TEXT NOT NULL
|
|
80
|
+
) STRICT;
|
|
73
81
|
`;
|
|
74
82
|
|
|
75
83
|
export const HUB_STORE_SCHEMA_SPEC: DatabaseSchemaSpec = Object.freeze({
|
|
76
|
-
schema:
|
|
84
|
+
schema: HUB_STORE_SCHEMA_V4,
|
|
77
85
|
version: HUB_STORE_SCHEMA_VERSION,
|
|
78
86
|
tables: HUB_STORE_TABLES,
|
|
79
87
|
});
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
/** The stored shape stays the durable identity plus the agent key: presence
|
|
90
|
+
* and the lease are derived on read, so the agents record JSON grows only by
|
|
91
|
+
* the additive `host` label. */
|
|
92
|
+
export interface StoredAgent extends AgentIdentity {
|
|
82
93
|
key: string;
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
/** Why a lease call did not get the resource. `held` is live contention,
|
|
97
|
+
* `superseded` is a token the hub has already moved past, `expired` is a lease
|
|
98
|
+
* whose hub-clocked deadline has passed, and `missing` is no lease at all. */
|
|
99
|
+
export type LeaseRefusal = "held" | "superseded" | "expired" | "missing";
|
|
100
|
+
|
|
101
|
+
export type LeaseOutcome =
|
|
102
|
+
| { ok: true; lease: LeaseRecord; renewed: boolean }
|
|
103
|
+
| { ok: false; reason: LeaseRefusal; lease?: LeaseRecord };
|
|
104
|
+
|
|
105
|
+
export interface LeaseAcquireInput {
|
|
106
|
+
resource: string;
|
|
107
|
+
project: string;
|
|
108
|
+
name: string;
|
|
109
|
+
holderAgentId: string;
|
|
110
|
+
holderAgentName: string;
|
|
111
|
+
ttlMs: number;
|
|
112
|
+
nowMs: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseLeaseRecord(record: string): LeaseRecord | undefined {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(record) as LeaseRecord;
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A deadline the hub cannot read is an expired deadline. The alternative — a
|
|
124
|
+
* lease nobody can ever take over — is the worse failure. */
|
|
125
|
+
function leaseHasLapsed(lease: LeaseRecord, nowMs: number): boolean {
|
|
126
|
+
const expiresAtMs = Date.parse(lease.expiresAt);
|
|
127
|
+
return !Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs;
|
|
128
|
+
}
|
|
129
|
+
|
|
85
130
|
class MessageMap extends Map<string, MessageRecord> {
|
|
86
131
|
private readonly store: MeshStore;
|
|
87
132
|
|
|
@@ -121,6 +166,7 @@ export class MeshStore {
|
|
|
121
166
|
readonly workflowRuns = new Map<string, WorkflowRun>();
|
|
122
167
|
readonly journal = new Map<string, WorkflowJournalEntry>();
|
|
123
168
|
readonly contextItems = new Map<string, ContextItem>();
|
|
169
|
+
readonly leases = new Map<string, LeaseRecord>();
|
|
124
170
|
readonly path?: string;
|
|
125
171
|
private readonly database?: DatabaseSync;
|
|
126
172
|
private readonly agentSequences = new Map<string, number>();
|
|
@@ -328,6 +374,140 @@ export class MeshStore {
|
|
|
328
374
|
return result;
|
|
329
375
|
}
|
|
330
376
|
|
|
377
|
+
/** Read the lease over one already project-scoped resource. */
|
|
378
|
+
getLease(resource: string): LeaseRecord | undefined {
|
|
379
|
+
if (!this.database) return this.leases.get(resource);
|
|
380
|
+
const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource) as { record: string } | undefined;
|
|
381
|
+
if (!row) {
|
|
382
|
+
this.leases.delete(resource);
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
const lease = parseLeaseRecord(row.record);
|
|
386
|
+
if (lease) this.leases.set(resource, lease);
|
|
387
|
+
return lease;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Take or extend the lease over `resource`, deciding the whole outcome inside
|
|
392
|
+
* one transaction so two writers cannot both read "free" and both insert.
|
|
393
|
+
*
|
|
394
|
+
* The token is the fence: a first acquisition starts at 1, the holder's own
|
|
395
|
+
* re-acquisition keeps its token, and only a takeover of an expired lease
|
|
396
|
+
* increments it. That is what lets a holder that wakes up after its deadline
|
|
397
|
+
* be refused at commit rather than silently writing behind the new holder.
|
|
398
|
+
*/
|
|
399
|
+
acquireLease(input: LeaseAcquireInput): LeaseOutcome {
|
|
400
|
+
return this.inLeaseTransaction(() => {
|
|
401
|
+
const current = this.readLeaseForUpdate(input.resource);
|
|
402
|
+
const expired = current !== undefined && leaseHasLapsed(current, input.nowMs);
|
|
403
|
+
if (current && !expired && current.holderAgentId !== input.holderAgentId) {
|
|
404
|
+
return { ok: false, reason: "held", lease: current };
|
|
405
|
+
}
|
|
406
|
+
const fencingToken = current === undefined
|
|
407
|
+
? 1
|
|
408
|
+
: expired
|
|
409
|
+
? current.fencingToken + 1
|
|
410
|
+
: current.fencingToken;
|
|
411
|
+
const renewed = current !== undefined && !expired;
|
|
412
|
+
const lease: LeaseRecord = {
|
|
413
|
+
resource: input.resource,
|
|
414
|
+
project: input.project,
|
|
415
|
+
name: input.name,
|
|
416
|
+
holderAgentId: input.holderAgentId,
|
|
417
|
+
holderAgentName: input.holderAgentName,
|
|
418
|
+
fencingToken,
|
|
419
|
+
acquiredAt: renewed && current ? current.acquiredAt : new Date(input.nowMs).toISOString(),
|
|
420
|
+
expiresAt: new Date(input.nowMs + input.ttlMs).toISOString(),
|
|
421
|
+
};
|
|
422
|
+
this.writeLease(lease);
|
|
423
|
+
return { ok: true, lease, renewed };
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Extend a lease the caller still holds under the token it was given. The
|
|
428
|
+
* token never changes on renewal — a renewal that would need a new token is
|
|
429
|
+
* a takeover, and takeovers go through `acquireLease`. */
|
|
430
|
+
renewLease(input: {
|
|
431
|
+
resource: string;
|
|
432
|
+
holderAgentId: string;
|
|
433
|
+
fencingToken: number;
|
|
434
|
+
ttlMs: number;
|
|
435
|
+
nowMs: number;
|
|
436
|
+
}): LeaseOutcome {
|
|
437
|
+
return this.inLeaseTransaction(() => {
|
|
438
|
+
const current = this.readLeaseForUpdate(input.resource);
|
|
439
|
+
if (!current) return { ok: false, reason: "missing" };
|
|
440
|
+
if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
|
|
441
|
+
return { ok: false, reason: "superseded", lease: current };
|
|
442
|
+
}
|
|
443
|
+
if (leaseHasLapsed(current, input.nowMs)) {
|
|
444
|
+
// The deadline passed, so the resource is takeable by anyone. Resurrecting it
|
|
445
|
+
// under the old token would let a second holder appear behind the first.
|
|
446
|
+
return { ok: false, reason: "expired", lease: current };
|
|
447
|
+
}
|
|
448
|
+
const lease: LeaseRecord = { ...current, expiresAt: new Date(input.nowMs + input.ttlMs).toISOString() };
|
|
449
|
+
this.writeLease(lease);
|
|
450
|
+
return { ok: true, lease, renewed: true };
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Drop a lease the caller holds. A clean release ends the fence: there is no
|
|
455
|
+
* stale writer left to keep a token for, so the next acquisition starts over. */
|
|
456
|
+
releaseLease(input: { resource: string; holderAgentId: string; fencingToken: number }): LeaseOutcome {
|
|
457
|
+
return this.inLeaseTransaction(() => {
|
|
458
|
+
const current = this.readLeaseForUpdate(input.resource);
|
|
459
|
+
if (!current) return { ok: false, reason: "missing" };
|
|
460
|
+
if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
|
|
461
|
+
return { ok: false, reason: "superseded", lease: current };
|
|
462
|
+
}
|
|
463
|
+
this.deleteLease(input.resource);
|
|
464
|
+
return { ok: true, lease: current, renewed: false };
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Every lease of one project, newest deadline last. Reader surface only. */
|
|
469
|
+
listLeases(project: string): LeaseRecord[] {
|
|
470
|
+
if (this.database) {
|
|
471
|
+
const rows = this.database.prepare("SELECT record FROM leases").all() as Array<{ record: string }>;
|
|
472
|
+
this.leases.clear();
|
|
473
|
+
for (const row of rows) {
|
|
474
|
+
const lease = parseLeaseRecord(row.record);
|
|
475
|
+
if (lease) this.leases.set(lease.resource, lease);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return [...this.leases.values()]
|
|
479
|
+
.filter((lease) => lease.project === project)
|
|
480
|
+
.sort((left, right) => left.resource.localeCompare(right.resource));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private inLeaseTransaction(work: () => LeaseOutcome): LeaseOutcome {
|
|
484
|
+
if (!this.database) return work();
|
|
485
|
+
return withDatabaseTransaction(this.database, work);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private readLeaseForUpdate(resource: string): LeaseRecord | undefined {
|
|
489
|
+
if (!this.database) return this.leases.get(resource);
|
|
490
|
+
const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource) as { record: string } | undefined;
|
|
491
|
+
return row ? parseLeaseRecord(row.record) : undefined;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private writeLease(lease: LeaseRecord): void {
|
|
495
|
+
this.leases.set(lease.resource, lease);
|
|
496
|
+
this.database?.prepare(`
|
|
497
|
+
INSERT INTO leases (resource, holder_agent_id, fencing_token, expires_at, record) VALUES (?, ?, ?, ?, ?)
|
|
498
|
+
ON CONFLICT(resource) DO UPDATE SET
|
|
499
|
+
holder_agent_id = excluded.holder_agent_id,
|
|
500
|
+
fencing_token = excluded.fencing_token,
|
|
501
|
+
expires_at = excluded.expires_at,
|
|
502
|
+
record = excluded.record
|
|
503
|
+
`).run(lease.resource, lease.holderAgentId, lease.fencingToken, lease.expiresAt, JSON.stringify(lease));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private deleteLease(resource: string): void {
|
|
507
|
+
this.leases.delete(resource);
|
|
508
|
+
this.database?.prepare("DELETE FROM leases WHERE resource = ?").run(resource);
|
|
509
|
+
}
|
|
510
|
+
|
|
331
511
|
deleteWorkflowRun(runId: string): void {
|
|
332
512
|
this.workflowRuns.delete(runId);
|
|
333
513
|
this.database?.prepare("DELETE FROM workflow_runs WHERE id = ?").run(runId);
|
|
@@ -352,6 +532,7 @@ export class MeshStore {
|
|
|
352
532
|
purgedRuns: string[];
|
|
353
533
|
purgedJournal: string[];
|
|
354
534
|
purgedContextItems: string[];
|
|
535
|
+
purgedLeases: LeaseRecord[];
|
|
355
536
|
} {
|
|
356
537
|
const messageCutoffMs = nowMs - messageRetentionMs;
|
|
357
538
|
const messageCutoffIso = new Date(messageCutoffMs).toISOString();
|
|
@@ -360,6 +541,7 @@ export class MeshStore {
|
|
|
360
541
|
const purgedRuns: string[] = [];
|
|
361
542
|
const purgedJournal: string[] = [];
|
|
362
543
|
const purgedContextItems: string[] = [];
|
|
544
|
+
const purgedLeases: LeaseRecord[] = [];
|
|
363
545
|
|
|
364
546
|
// 1. Messages
|
|
365
547
|
if (!this.database) {
|
|
@@ -438,7 +620,31 @@ export class MeshStore {
|
|
|
438
620
|
purgedContextItems.push(id);
|
|
439
621
|
}
|
|
440
622
|
|
|
441
|
-
|
|
623
|
+
// 5. Leases. An expired lease row is kept on purpose — its token is what a
|
|
624
|
+
// takeover increments, so reaping it the moment it lapses would let the
|
|
625
|
+
// fence restart at 1 while a stale holder was still alive. Only rows whose
|
|
626
|
+
// deadline is older than the retention window, far beyond any live holder's
|
|
627
|
+
// TTL, are dropped.
|
|
628
|
+
const leaseCutoffMs = nowMs - runRetentionMs;
|
|
629
|
+
for (const lease of this.listAllLeases()) {
|
|
630
|
+
if (leaseHasLapsed(lease, leaseCutoffMs)) {
|
|
631
|
+
this.deleteLease(lease.resource);
|
|
632
|
+
purgedLeases.push(lease);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return { purgedMessages, purgedRuns, purgedJournal, purgedContextItems, purgedLeases };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private listAllLeases(): LeaseRecord[] {
|
|
640
|
+
if (!this.database) return [...this.leases.values()];
|
|
641
|
+
const rows = this.database.prepare("SELECT record FROM leases").all() as Array<{ record: string }>;
|
|
642
|
+
const result: LeaseRecord[] = [];
|
|
643
|
+
for (const row of rows) {
|
|
644
|
+
const lease = parseLeaseRecord(row.record);
|
|
645
|
+
if (lease) result.push(lease);
|
|
646
|
+
}
|
|
647
|
+
return result;
|
|
442
648
|
}
|
|
443
649
|
|
|
444
650
|
saveWorkflowRun(run: WorkflowRun): void {
|
|
@@ -525,6 +731,7 @@ export class MeshStore {
|
|
|
525
731
|
const workflowRows = this.database.prepare("SELECT record FROM workflow_runs").all() as Array<{ record: string }>;
|
|
526
732
|
const journalRows = this.database.prepare("SELECT record FROM workflow_journal").all() as Array<{ record: string }>;
|
|
527
733
|
const contextRows = this.database.prepare("SELECT record FROM context_items").all() as Array<{ record: string }>;
|
|
734
|
+
const leaseRows = this.database.prepare("SELECT record FROM leases").all() as Array<{ record: string }>;
|
|
528
735
|
for (const row of agentRows) {
|
|
529
736
|
const agent = JSON.parse(row.record) as StoredAgent;
|
|
530
737
|
this.agents.set(agent.id, agent);
|
|
@@ -541,5 +748,9 @@ export class MeshStore {
|
|
|
541
748
|
const item = JSON.parse(row.record) as ContextItem;
|
|
542
749
|
this.contextItems.set(item.id, item);
|
|
543
750
|
}
|
|
751
|
+
for (const row of leaseRows) {
|
|
752
|
+
const lease = parseLeaseRecord(row.record);
|
|
753
|
+
if (lease) this.leases.set(lease.resource, lease);
|
|
754
|
+
}
|
|
544
755
|
}
|
|
545
756
|
}
|
package/plugins/kxm/src/tui.ts
CHANGED
|
@@ -310,6 +310,19 @@ function visibleAgents(snapshot: MeshTuiSnapshot): AgentRecord[] {
|
|
|
310
310
|
return snapshot.agents.filter((agent) => agent.model !== "tui");
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/** Presence comes from the hub clock, so the dash colours it rather than
|
|
314
|
+
* recomputing it: green holds a lease, amber has lost it but has not been
|
|
315
|
+
* swept, dim is a registered peer that is gone. */
|
|
316
|
+
function presenceCell(agent: AgentRecord, theme: MeshTuiTheme, width = 7): string {
|
|
317
|
+
// Local-store projections carry no hub-clocked presence; the dash shows "n/a"
|
|
318
|
+
// rather than inventing a lease from the reader's clock.
|
|
319
|
+
const presence = agent.presence ?? "n/a";
|
|
320
|
+
const cell = pad(presence, width);
|
|
321
|
+
if (presence === "online") return theme.success(cell);
|
|
322
|
+
if (presence === "stale") return theme.warning(cell);
|
|
323
|
+
return theme.dim(cell);
|
|
324
|
+
}
|
|
325
|
+
|
|
313
326
|
function panelMetric(snapshot: MeshTuiSnapshot, panel: MeshTuiPanel): string {
|
|
314
327
|
if (panel === "agents") {
|
|
315
328
|
const agents = visibleAgents(snapshot);
|
|
@@ -441,7 +454,7 @@ function listLines(snapshot: MeshTuiSnapshot, view: MeshTuiView, theme: MeshTuiT
|
|
|
441
454
|
const mark = (index: number) => cursor(theme, index === view.selected, "list", view.pane);
|
|
442
455
|
if (view.tab === "agents") {
|
|
443
456
|
return visibleAgents(snapshot).map((agent, index) => (
|
|
444
|
-
`${mark(index)}${pad(agent.name, 14)} ${agent
|
|
457
|
+
`${mark(index)}${pad(agent.name, 14)} ${presenceCell(agent, theme)} ${pad(agent.host ?? "-", 12)} ${pad(agent.model ?? "-", 14)} ${pad(age(agent.lastSeenAt, now), 4)}`
|
|
445
458
|
));
|
|
446
459
|
}
|
|
447
460
|
if (view.tab === "tasks" || view.tab === "workflows") {
|
|
@@ -489,9 +502,10 @@ function detailLines(snapshot: MeshTuiSnapshot, view: MeshTuiView, theme: MeshTu
|
|
|
489
502
|
const related = snapshot.openMessages.filter((message) => message.fromName === agent.name || message.toName === agent.name);
|
|
490
503
|
return [
|
|
491
504
|
theme.accent(agent.name),
|
|
492
|
-
`${agent
|
|
505
|
+
`${presenceCell(agent, theme, (agent.presence ?? "n/a").length)} ${agent.model ?? "-"}`,
|
|
506
|
+
`host ${agent.host ?? "-"}`,
|
|
493
507
|
agent.purpose,
|
|
494
|
-
`seen ${age(agent.lastSeenAt, now)} ago`,
|
|
508
|
+
`seen ${age(agent.lastSeenAt, now)} ago · lease ${agent.leaseExpiresAt?.slice(11, 19) ?? "n/a"} UTC`,
|
|
495
509
|
"",
|
|
496
510
|
theme.dim("Open work"),
|
|
497
511
|
...(related.length === 0 ? [theme.dim("none")] : related.map((message) => `${message.status} ${message.fromName} → ${message.toName} ${age(message.createdAt, now)}`)),
|
|
@@ -875,7 +889,9 @@ export async function runMeshTui(input: {
|
|
|
875
889
|
}
|
|
876
890
|
if (!useOpsStream && identity) {
|
|
877
891
|
try {
|
|
878
|
-
|
|
892
|
+
// Offline members too: the ops snapshot lists the whole project, and
|
|
893
|
+
// the legacy path is a fallback for the same screen, not a narrower one.
|
|
894
|
+
const listed = await input.fetchImpl(`${base}/v1/agents?includeOffline=true`, { headers: headers(identity) });
|
|
879
895
|
if (listed.ok) {
|
|
880
896
|
const body = await readJson<{ agents: AgentRecord[] }>(listed);
|
|
881
897
|
const byId = new Map(local.agents.map((agent) => [agent.id, agent]));
|