agent-comms 8.15.8 → 8.15.10
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/.claude-plugin/plugin.json +1 -1
- package/dist/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +44 -24
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
- package/dist/bridges/user/web/web-assets.d.ts +28 -0
- package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
- package/dist/bridges/user/web/web-assets.js +67 -0
- package/dist/bridges/user/web/web-assets.js.map +1 -0
- package/package.json +8 -2
- package/src/account-cli.ts +195 -0
- package/src/account-join-cli.ts +191 -0
- package/src/bridges/cc-peer/approval-commands.ts +82 -0
- package/src/bridges/cc-peer/bridge.ts +138 -0
- package/src/bridges/cc-peer/default-front.ts +58 -0
- package/src/bridges/cc-peer/front-controller.ts +160 -0
- package/src/bridges/cc-peer/front-relay.ts +191 -0
- package/src/bridges/cc-peer/front-runtime.ts +167 -0
- package/src/bridges/cc-peer/front.ts +59 -0
- package/src/bridges/cc-peer/reply-aliases.ts +90 -0
- package/src/bridges/cc-peer/run.ts +100 -0
- package/src/bridges/cc-peer/shared-peer.ts +28 -0
- package/src/bridges/claude-code/channel.ts +318 -0
- package/src/bridges/codex/tool.ts +132 -0
- package/src/bridges/mcp/server.ts +137 -0
- package/src/bridges/opencode/plugin.ts +130 -0
- package/src/bridges/pi/extension.ts +330 -0
- package/src/bridges/registry.ts +17 -0
- package/src/bridges/user/cli.ts +87 -0
- package/src/bridges/user/controller.ts +345 -0
- package/src/bridges/user/tui.ts +300 -0
- package/src/bridges/user/web/actions.ts +173 -0
- package/src/bridges/user/web/contract.ts +222 -0
- package/src/bridges/user/web/dist/bundle.js +49 -0
- package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
- package/src/bridges/user/web/dist/index.html +21 -0
- package/src/bridges/user/web/dist/manifest.json +26 -0
- package/src/bridges/user/web/dist/mesh-worker.js +64 -0
- package/src/bridges/user/web/dist/sw.js +1 -0
- package/src/bridges/user/web/event-publisher.ts +114 -0
- package/src/bridges/user/web/port-discovery.ts +49 -0
- package/src/bridges/user/web/reads.ts +35 -0
- package/src/bridges/user/web/router.ts +158 -0
- package/src/bridges/user/web/server.ts +782 -0
- package/src/bridges/user/web/vite.config.ts +71 -0
- package/src/bridges/user/web/web-access.ts +404 -0
- package/src/bridges/user/web/web-assets.ts +90 -0
- package/src/bridges/user/web/web-beacon.ts +69 -0
- package/src/bridges/user/web/web-console-static.ts +90 -0
- package/src/cli-main.ts +635 -0
- package/src/cli.ts +13 -0
- package/src/core/account-bundle.ts +264 -0
- package/src/core/account-join.ts +289 -0
- package/src/core/account-ledger-crypto.ts +154 -0
- package/src/core/account-ledger-store.ts +66 -0
- package/src/core/account-ledger.ts +385 -0
- package/src/core/agent-details.ts +216 -0
- package/src/core/agent-list-format.ts +97 -0
- package/src/core/agent-registry.ts +282 -0
- package/src/core/agent-table.ts +58 -0
- package/src/core/atomic-file.ts +46 -0
- package/src/core/bind-retry.ts +38 -0
- package/src/core/bridge-mesh.ts +176 -0
- package/src/core/bridge.ts +718 -0
- package/src/core/capability-ask.ts +162 -0
- package/src/core/capability-bubble-up.ts +237 -0
- package/src/core/comms-store.ts +130 -0
- package/src/core/connection-approval.ts +134 -0
- package/src/core/connection-code-tool.ts +92 -0
- package/src/core/connection-code.ts +296 -0
- package/src/core/coordinator-role.ts +224 -0
- package/src/core/data-directory.ts +32 -0
- package/src/core/data-frame-routing.ts +309 -0
- package/src/core/default-command.ts +12 -0
- package/src/core/delegation-policy.ts +41 -0
- package/src/core/delivery-engine.ts +649 -0
- package/src/core/device-membership-verification.ts +125 -0
- package/src/core/device-membership.ts +130 -0
- package/src/core/directory-admission.ts +155 -0
- package/src/core/discovery-mdns.ts +247 -0
- package/src/core/discovery-tailscale.ts +224 -0
- package/src/core/discovery.ts +303 -0
- package/src/core/display-name.ts +73 -0
- package/src/core/dm-grant-actions.ts +72 -0
- package/src/core/dm-send-delegation.ts +73 -0
- package/src/core/dm-token-verification.ts +76 -0
- package/src/core/election-sessions.ts +135 -0
- package/src/core/error-reporting.ts +85 -0
- package/src/core/first-contact.ts +242 -0
- package/src/core/gateway-trust-actions.ts +184 -0
- package/src/core/gateway-trust.ts +305 -0
- package/src/core/gossip-directory.ts +276 -0
- package/src/core/gossip-extensions.ts +123 -0
- package/src/core/group-proofs.ts +205 -0
- package/src/core/handshake.ts +354 -0
- package/src/core/hub-forwarding.ts +24 -0
- package/src/core/hub-link.ts +176 -0
- package/src/core/hub-session.ts +374 -0
- package/src/core/identity-store.ts +715 -0
- package/src/core/identity.ts +489 -0
- package/src/core/index.ts +65 -0
- package/src/core/issuer-grouping.ts +165 -0
- package/src/core/issuer-identity-file.ts +187 -0
- package/src/core/lan-web-actions.ts +34 -0
- package/src/core/lan-web-uis.ts +55 -0
- package/src/core/ledger-lock.ts +81 -0
- package/src/core/listener-registry.ts +133 -0
- package/src/core/loopback-address.ts +22 -0
- package/src/core/machine-identity.ts +77 -0
- package/src/core/machine-list-groups.ts +53 -0
- package/src/core/mcp-registry-retry.ts +69 -0
- package/src/core/mcp-registry-version.ts +18 -0
- package/src/core/membership-proof.ts +139 -0
- package/src/core/membership-proofs.ts +149 -0
- package/src/core/mesh-graph-trace-tool.ts +81 -0
- package/src/core/mesh-graph.ts +89 -0
- package/src/core/mesh-network-actions.ts +183 -0
- package/src/core/mesh-store-options.ts +23 -0
- package/src/core/mesh-store-shared.ts +134 -0
- package/src/core/mesh-store.ts +1114 -0
- package/src/core/name-claim.ts +145 -0
- package/src/core/naming-actions.ts +90 -0
- package/src/core/naming.ts +138 -0
- package/src/core/nanoid.ts +20 -0
- package/src/core/network-interfaces.ts +24 -0
- package/src/core/package-version.ts +34 -0
- package/src/core/peer-lifecycle.ts +377 -0
- package/src/core/peer-link-error.ts +41 -0
- package/src/core/peer-versions.ts +98 -0
- package/src/core/pending-connection.ts +53 -0
- package/src/core/petnames.ts +121 -0
- package/src/core/pgp-keyserver.ts +32 -0
- package/src/core/pid-lock-file.ts +122 -0
- package/src/core/push-manager.ts +76 -0
- package/src/core/random-id.ts +9 -0
- package/src/core/receiver-set.ts +42 -0
- package/src/core/relay-role.ts +182 -0
- package/src/core/relay-selection.ts +120 -0
- package/src/core/relay-server.ts +198 -0
- package/src/core/relay-uplink.ts +78 -0
- package/src/core/request-timeouts.ts +37 -0
- package/src/core/room-discovery.ts +48 -0
- package/src/core/room-lifecycle.ts +838 -0
- package/src/core/room-lookup.ts +41 -0
- package/src/core/room-messaging.ts +247 -0
- package/src/core/room-notice-log.ts +61 -0
- package/src/core/room-path.ts +84 -0
- package/src/core/room-protocol.ts +893 -0
- package/src/core/room-router.ts +180 -0
- package/src/core/room-token-verification.ts +85 -0
- package/src/core/room-wire-extensions.ts +60 -0
- package/src/core/self-name-claim.ts +67 -0
- package/src/core/send-outcome.ts +69 -0
- package/src/core/shutdown-signals.ts +57 -0
- package/src/core/stale-agent-checker.ts +108 -0
- package/src/core/store.ts +704 -0
- package/src/core/token-text.ts +40 -0
- package/src/core/tool.ts +853 -0
- package/src/core/transport-events.ts +73 -0
- package/src/core/transport-queries.ts +54 -0
- package/src/core/transport.ts +367 -0
- package/src/core/types.ts +550 -0
- package/src/core/user-identity.ts +290 -0
- package/src/core/vapid.ts +81 -0
- package/src/core/version-check.ts +147 -0
- package/src/core/version-report-actions.ts +79 -0
- package/src/core/web-beacon.ts +31 -0
- package/src/core/web-push.ts +322 -0
- package/src/core/wire-mesh-core-version.ts +25 -0
- package/src/core/wire-mesh-identity.ts +36 -0
- package/src/core/wire-mesh-transport-options.ts +82 -0
- package/src/core/wire-mesh-transport.ts +1007 -0
- package/src/core/wire-protocol.ts +153 -0
- package/src/core/ws-dial.ts +191 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The details a trusted remote peer asks for over a session of its own (agent-comms#323): a hub session publishes only an agent's summary, so its working directory, rooms and tags list elsewhere as not shared. A bridge this machine has chosen to trust may ask for them with the agent-comms/agent-details verb, carried over the relay pairing the two already have so the hub relays bytes it cannot read; the answer is the same summary a direct peer reads from the advert.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ManageOutcome } from "wire-mesh-core/domain/mesh-session";
|
|
6
|
+
import type { RoomVerbHandler } from "./room-router.js";
|
|
7
|
+
import type { MeshTransport } from "./transport.js";
|
|
8
|
+
import { detailsShared } from "./agent-registry.js";
|
|
9
|
+
import {
|
|
10
|
+
JOIN_ROUTE_RETRY_MS,
|
|
11
|
+
JOIN_ROUTE_WAIT_MS,
|
|
12
|
+
NO_ROUTE_CODES,
|
|
13
|
+
} from "./account-join.js";
|
|
14
|
+
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
15
|
+
import type { AgentIdentity } from "./types.js";
|
|
16
|
+
import type { HostedRoomAdvert } from "./gossip-extensions.js";
|
|
17
|
+
|
|
18
|
+
/** The params.verb a trusted peer's request is dispatched by, keyed per room-router's own convention like account-join's "account.join". */
|
|
19
|
+
export const AGENT_DETAILS_VERB = "agent.details";
|
|
20
|
+
|
|
21
|
+
/** The outer manage-command verb the request rides, which only satisfies manage-command.verb's own capability-verb grammar; the receiving side dispatches on AGENT_DETAILS_VERB, never this. */
|
|
22
|
+
export const AGENT_DETAILS_CAPABILITY_VERB = "agent:details";
|
|
23
|
+
|
|
24
|
+
/** One room as the details answer carries it: the hosted-room facts a direct peer's advert includes, description included. */
|
|
25
|
+
export interface AgentDetailsRoom {
|
|
26
|
+
path: string;
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The details a trusted peer gets that a hub session withholds: everything but the summary. */
|
|
32
|
+
export interface AgentDetails {
|
|
33
|
+
cwd: string;
|
|
34
|
+
tags: readonly string[];
|
|
35
|
+
rooms: readonly AgentDetailsRoom[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the verb handler needs: this side's own current details (undefined while no agent is registered), and the trust boundary the requester is gated on. */
|
|
39
|
+
export interface AgentDetailsDeps {
|
|
40
|
+
details: () => AgentDetails | undefined;
|
|
41
|
+
isTrusted: (deviceHex: string) => boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Answers the agent.details verb: the full details for a requester this machine trusts by device id, an explicit refusal for anyone else, and no agent yet for a bridge that has not registered. The requester is whoever the transport actually authenticated: the device the end-to-end secure channel named for a relayed request (fromDevice, never an address a hub asserts), or the connection's own authenticated peer for a direct session (the handle's id, which every dispatch path builds from exactly that). */
|
|
45
|
+
export function agentDetailsVerbHandler(
|
|
46
|
+
deps: Readonly<AgentDetailsDeps>,
|
|
47
|
+
): RoomVerbHandler {
|
|
48
|
+
return async (request, handle) => {
|
|
49
|
+
const requesterHex =
|
|
50
|
+
request.fromDevice !== undefined
|
|
51
|
+
? deviceIdToHex(request.fromDevice)
|
|
52
|
+
: handle.id;
|
|
53
|
+
if (!deps.isTrusted(requesterHex)) {
|
|
54
|
+
return { result: "error", code: "untrusted_requester" };
|
|
55
|
+
}
|
|
56
|
+
const details = deps.details();
|
|
57
|
+
if (details === undefined) {
|
|
58
|
+
return { result: "error", code: "no_agent" };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
result: "ok",
|
|
62
|
+
cwd: details.cwd,
|
|
63
|
+
tags: details.tags,
|
|
64
|
+
rooms: details.rooms,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Narrows an untrusted value into one AgentDetailsRoom. */
|
|
70
|
+
function isAgentDetailsRoom(value: unknown): value is AgentDetailsRoom {
|
|
71
|
+
if (typeof value !== "object" || value === null) return false;
|
|
72
|
+
if (!("path" in value) || typeof value.path !== "string") return false;
|
|
73
|
+
if (!("name" in value) || typeof value.name !== "string") return false;
|
|
74
|
+
if (!("description" in value) || typeof value.description !== "string") {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Narrows a manage outcome into the AgentDetails it carries: an ok outcome whose every field has the expected shape, and nothing else (a refusal, or an answer from a peer running something else). */
|
|
81
|
+
export function parseAgentDetails(
|
|
82
|
+
outcome: Readonly<ManageOutcome>,
|
|
83
|
+
): AgentDetails | undefined {
|
|
84
|
+
if (outcome.result !== "ok") return undefined;
|
|
85
|
+
if (typeof outcome.cwd !== "string") return undefined;
|
|
86
|
+
if (!Array.isArray(outcome.tags)) return undefined;
|
|
87
|
+
if (outcome.tags.some((tag) => typeof tag !== "string")) return undefined;
|
|
88
|
+
if (!Array.isArray(outcome.rooms)) return undefined;
|
|
89
|
+
const rooms: AgentDetailsRoom[] = [];
|
|
90
|
+
for (const room of outcome.rooms) {
|
|
91
|
+
if (!isAgentDetailsRoom(room)) return undefined;
|
|
92
|
+
rooms.push(room);
|
|
93
|
+
}
|
|
94
|
+
return { cwd: outcome.cwd, tags: outcome.tags, rooms };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The cache of details this side has asked trusted peers for: refreshed whenever a listing triggers a new fetch, and trimmed to the agents the last listing actually showed so a departed agent's entry does not linger. */
|
|
98
|
+
export class AgentDetailsCache {
|
|
99
|
+
private readonly entries = new Map<string, AgentDetails>();
|
|
100
|
+
|
|
101
|
+
/** Records (or replaces) the details fetched for one agent. */
|
|
102
|
+
set(agentId: string, details: Readonly<AgentDetails>): void {
|
|
103
|
+
this.entries.set(agentId, { ...details, rooms: [...details.rooms] });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The cached details for one agent, if a fetch has completed. */
|
|
107
|
+
get(agentId: string): Readonly<AgentDetails> | undefined {
|
|
108
|
+
return this.entries.get(agentId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Drops every entry whose agent the last listing did not include. */
|
|
112
|
+
retain(agentIds: ReadonlySet<string>): void {
|
|
113
|
+
for (const agentId of this.entries.keys()) {
|
|
114
|
+
if (!agentIds.has(agentId)) this.entries.delete(agentId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** This side's own details for a registered agent and its hosted rooms, the verb's answer body. */
|
|
120
|
+
export function ownAgentDetailsOf(
|
|
121
|
+
agent: Readonly<Pick<AgentIdentity, "cwd" | "tags">>,
|
|
122
|
+
hostedRooms: readonly Readonly<HostedRoomAdvert>[],
|
|
123
|
+
): AgentDetails {
|
|
124
|
+
return {
|
|
125
|
+
cwd: agent.cwd,
|
|
126
|
+
tags: agent.tags,
|
|
127
|
+
rooms: hostedRooms.map((room) => ({
|
|
128
|
+
path: room.path,
|
|
129
|
+
name: room.name,
|
|
130
|
+
description: room.description,
|
|
131
|
+
})),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What the exchange needs from the store: this side's own agent and hosted rooms for answers, the trust boundary both directions gate on, a way to send a request to a peer, and this side's own peer id. */
|
|
136
|
+
export interface AgentDetailsExchangeDeps {
|
|
137
|
+
selfAgent: () => Readonly<Pick<AgentIdentity, "cwd" | "tags">> | undefined;
|
|
138
|
+
hostedRooms: () => readonly Readonly<HostedRoomAdvert>[];
|
|
139
|
+
isTrusted: (deviceHex: string) => boolean;
|
|
140
|
+
requireTransport: () => Readonly<Pick<MeshTransport, "sendRoomRequest">>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Both halves of agent-comms#323 in one place: the verb a trusted peer's request is answered by, and the background fetch that fills this side's cache from peers this machine trusts. */
|
|
144
|
+
export class AgentDetailsExchange {
|
|
145
|
+
private readonly cache = new AgentDetailsCache();
|
|
146
|
+
|
|
147
|
+
/** The registered verb handler, answering from this side's own agent and hosted rooms. */
|
|
148
|
+
readonly handler: RoomVerbHandler;
|
|
149
|
+
|
|
150
|
+
constructor(private readonly deps: Readonly<AgentDetailsExchangeDeps>) {
|
|
151
|
+
this.handler = agentDetailsVerbHandler({
|
|
152
|
+
details: () => {
|
|
153
|
+
const agent = deps.selfAgent();
|
|
154
|
+
return agent === undefined
|
|
155
|
+
? undefined
|
|
156
|
+
: ownAgentDetailsOf(agent, deps.hostedRooms());
|
|
157
|
+
},
|
|
158
|
+
isTrusted: deps.isTrusted,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Asks every trusted remote agent whose details a hub session withholds for them, without waiting: answers land in the cache and show on the next listing, and an unreachable peer simply keeps its not-shared listing. selfPeerId is the asking side's own agent, excluded from the fetches. */
|
|
163
|
+
refresh(
|
|
164
|
+
agents: readonly Readonly<AgentIdentity>[],
|
|
165
|
+
selfPeerId: string,
|
|
166
|
+
): void {
|
|
167
|
+
const shown = new Set<string>();
|
|
168
|
+
for (const agent of agents) {
|
|
169
|
+
shown.add(agent.id);
|
|
170
|
+
if (agent.id === selfPeerId) continue;
|
|
171
|
+
if (detailsShared(agent)) continue;
|
|
172
|
+
if (!this.deps.isTrusted(agent.id)) continue;
|
|
173
|
+
void this.fetch(agent.id);
|
|
174
|
+
}
|
|
175
|
+
this.cache.retain(shown);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The cached details for one remote agent, once its trusted peer has answered. */
|
|
179
|
+
cached(agentId: string): Readonly<AgentDetails> | undefined {
|
|
180
|
+
return this.cache.get(agentId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One agent's fetch, retried while no route to it exists yet (a relay pairing takes a moment to come up) and settled by the first real answer either way. */
|
|
184
|
+
private async fetch(agentId: string): Promise<void> {
|
|
185
|
+
const deadline = Date.now() + JOIN_ROUTE_WAIT_MS;
|
|
186
|
+
for (;;) {
|
|
187
|
+
let outcome;
|
|
188
|
+
try {
|
|
189
|
+
outcome = await this.deps.requireTransport().sendRoomRequest(
|
|
190
|
+
agentId,
|
|
191
|
+
{
|
|
192
|
+
verb: AGENT_DETAILS_CAPABILITY_VERB,
|
|
193
|
+
params: { verb: AGENT_DETAILS_VERB },
|
|
194
|
+
},
|
|
195
|
+
{ kind: "node" },
|
|
196
|
+
);
|
|
197
|
+
} catch {
|
|
198
|
+
// An unreachable peer is routine (it is listed from its last advert); its details stay not shared.
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const details = parseAgentDetails(outcome);
|
|
202
|
+
if (details !== undefined) {
|
|
203
|
+
this.cache.set(agentId, details);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// A pairing still warming answers "timeout" before it answers the verb, so that outcome is retried within the window too: the listing refreshes on every call, and a details fetch is never worth more than the wait.
|
|
207
|
+
const transient =
|
|
208
|
+
outcome.result === "error" &&
|
|
209
|
+
(NO_ROUTE_CODES.has(outcome.code) || outcome.code === "timeout");
|
|
210
|
+
if (!transient || Date.now() >= deadline) return;
|
|
211
|
+
await new Promise<void>((resolve) => {
|
|
212
|
+
setTimeout(resolve, JOIN_ROUTE_RETRY_MS);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list_agents listing's formatting, split out of tool.ts to keep that file under the repo's max-lines cap: the table, the per-agent describe block with rooms and versions, and the machine grouping.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Namer } from "./naming.js";
|
|
6
|
+
import { detailsShared } from "./agent-registry.js";
|
|
7
|
+
import { groupAgentsByMachine } from "./machine-list-groups.js";
|
|
8
|
+
import { agentTable, type AgentTableRow } from "./agent-table.js";
|
|
9
|
+
import {
|
|
10
|
+
formatListedAgentVersions,
|
|
11
|
+
type VersionReportStore,
|
|
12
|
+
} from "./version-report-actions.js";
|
|
13
|
+
import type { CommsResult } from "./tool.js";
|
|
14
|
+
import type { AgentIdentity } from "./types.js";
|
|
15
|
+
import type { AgentDetailsExchange } from "./agent-details.js";
|
|
16
|
+
|
|
17
|
+
/** What the renderer needs beyond the agents themselves: who is asking, their namer, and the store's optional reads (versions, machines, and the agent-details exchange's cache). */
|
|
18
|
+
export interface AgentListFormatStore extends Readonly<
|
|
19
|
+
Pick<
|
|
20
|
+
VersionReportStore,
|
|
21
|
+
| "getPeerAgentCommsVersion"
|
|
22
|
+
| "getPeerWireMeshCoreVersion"
|
|
23
|
+
| "getPeerCcPeerVersion"
|
|
24
|
+
>
|
|
25
|
+
> {
|
|
26
|
+
getCcPeerVersion?: (() => string | undefined) | undefined;
|
|
27
|
+
getMachineId?: (() => string | undefined) | undefined;
|
|
28
|
+
listAgentMachines?: (() => Promise<Map<string, string>>) | undefined;
|
|
29
|
+
agentDetailsExchange?: Readonly<Pick<AgentDetailsExchange, "cached">>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Renders the whole listing. A trusted remote agent's fetched details (agent-comms#323) stand in for the ones a hub session withholds; a fetch that has not come back yet keeps its not-shared listing until the next call. */
|
|
33
|
+
export async function renderAgentList(
|
|
34
|
+
agents: readonly Readonly<AgentIdentity>[],
|
|
35
|
+
opts: Readonly<{
|
|
36
|
+
selfId: string;
|
|
37
|
+
namer: Namer;
|
|
38
|
+
store: Readonly<AgentListFormatStore>;
|
|
39
|
+
}>,
|
|
40
|
+
): Promise<CommsResult> {
|
|
41
|
+
const homedir = process.env.HOME ?? "";
|
|
42
|
+
const abbreviateCwd = (cwd: string): string =>
|
|
43
|
+
homedir && cwd.startsWith(homedir) ? `~${cwd.slice(homedir.length)}` : cwd;
|
|
44
|
+
|
|
45
|
+
// The full id is printed in its own column, so the name column carries the names alone.
|
|
46
|
+
const rowOf = (a: Readonly<AgentIdentity>): AgentTableRow => {
|
|
47
|
+
const asked = opts.store.agentDetailsExchange?.cached(a.id);
|
|
48
|
+
const shared = detailsShared(a) || asked !== undefined;
|
|
49
|
+
return {
|
|
50
|
+
id: a.id,
|
|
51
|
+
name: opts.namer(a.id, { selfName: a.name, besideFullId: true }),
|
|
52
|
+
harness: a.harness,
|
|
53
|
+
status: a.status,
|
|
54
|
+
visibility: a.visibility,
|
|
55
|
+
cwd: `${shared ? abbreviateCwd(asked?.cwd ?? a.cwd) : "(not shared)"}${a.id === opts.selfId ? " (you)" : ""}`,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
const table = agentTable(agents.map(rowOf));
|
|
59
|
+
const describe = (a: Readonly<AgentIdentity>): string => {
|
|
60
|
+
const isSelf = a.id === opts.selfId;
|
|
61
|
+
const asked = opts.store.agentDetailsExchange?.cached(a.id);
|
|
62
|
+
const shared = detailsShared(a) || asked !== undefined;
|
|
63
|
+
const roomPaths =
|
|
64
|
+
asked === undefined ? a.subscribedRooms : asked.rooms.map((r) => r.path);
|
|
65
|
+
const rooms = !shared
|
|
66
|
+
? "not shared"
|
|
67
|
+
: roomPaths.length > 0
|
|
68
|
+
? roomPaths.join(", ")
|
|
69
|
+
: "none";
|
|
70
|
+
const versions = formatListedAgentVersions(
|
|
71
|
+
opts.store,
|
|
72
|
+
a.id,
|
|
73
|
+
isSelf,
|
|
74
|
+
opts.store.getCcPeerVersion,
|
|
75
|
+
);
|
|
76
|
+
return `${table.line(rowOf(a))}\n Rooms: ${rooms}\n Versions: ${versions}`;
|
|
77
|
+
};
|
|
78
|
+
const machines = await opts.store.listAgentMachines?.();
|
|
79
|
+
const groups = groupAgentsByMachine(
|
|
80
|
+
agents,
|
|
81
|
+
machines,
|
|
82
|
+
opts.store.getMachineId?.(),
|
|
83
|
+
opts.namer,
|
|
84
|
+
);
|
|
85
|
+
const body = groups
|
|
86
|
+
.map((group) => {
|
|
87
|
+
const rows = group.agents.map((a) => ` ${describe(a)}`);
|
|
88
|
+
return group.heading === undefined
|
|
89
|
+
? rows.join("\n")
|
|
90
|
+
: [` ${group.heading}`, ...rows].join("\n");
|
|
91
|
+
})
|
|
92
|
+
.join("\n");
|
|
93
|
+
return {
|
|
94
|
+
content: `Agents:\n ${table.header}\n${body}`,
|
|
95
|
+
isError: false,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentRegistry — the agent-identity half of CommsStore: the per-(harness, cwd) identity cache readIdentity/writeIdentity uses to recognise a restarted bridge as the same agent, and the register/get/update/list/offline lifecycle around an AgentIdentity record itself. Split out of mesh-store.ts to reduce it under the repo's max-lines cap.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parseDisplayName } from "./display-name.js";
|
|
6
|
+
import { CommsError, requireAgentName } from "./store.js";
|
|
7
|
+
import type { DeliveryEngine } from "./delivery-engine.js";
|
|
8
|
+
import type { MeshTransport } from "./transport.js";
|
|
9
|
+
import type { ReceivedAgentSelfAdvert } from "./gossip-extensions.js";
|
|
10
|
+
import { AgentStatus } from "./types.js";
|
|
11
|
+
import type { AgentIdentity, Visibility } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** snapshot-seconds is epoch seconds (mesh-session.ts's own `Math.floor(clock.now() / 1000)`); presenceStatusFor compares it against Date.now(), which is milliseconds. */
|
|
14
|
+
const MS_PER_SECOND = 1000;
|
|
15
|
+
|
|
16
|
+
/** The state and collaborators AgentRegistry needs from MeshStore. agents/identityCache are direct references into MeshStore's own fields; startedAt is a readonly value copied once; deliveryEngine is the already-constructed instance, narrowed to what agent-lifecycle bookkeeping ever needs. */
|
|
17
|
+
export interface AgentRegistryDeps {
|
|
18
|
+
agents: Map<string, AgentIdentity>;
|
|
19
|
+
identityCache: Map<string, { id: string }>;
|
|
20
|
+
startedAt: string;
|
|
21
|
+
getPeerId: () => string;
|
|
22
|
+
requireTransport: () => MeshTransport;
|
|
23
|
+
/** How old a gossip-discovered device's own last-heard advert (its snapshot-seconds) may be before listDiscoverableAgents stops trusting whatever presence value it last carried and reports it offline instead (agent-comms#301). Defaults to DEFAULT_PRESENCE_STALE_AFTER_MS; MeshStore threads its own presenceStaleAfterMs constructor option through here. */
|
|
24
|
+
presenceStaleAfterMs: number;
|
|
25
|
+
deliveryEngine: Pick<
|
|
26
|
+
DeliveryEngine,
|
|
27
|
+
| "bump"
|
|
28
|
+
| "broadcastPatch"
|
|
29
|
+
| "notifyRoomsOfStatus"
|
|
30
|
+
| "notifyRoomsOfNameChange"
|
|
31
|
+
>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Narrows an untrusted gossiped value (WireMeshTransport.listKnownDevices' own advert["agent/self"], self-asserted by whichever peer advertised it) into an AgentSelfAdvert -- a malformed or non-conforming entry is silently skipped rather than treated as an error, the same convention room-lifecycle.ts's own isHostedRoomAdvert already established for the identical class of gossip consumption. */
|
|
35
|
+
function isAgentSelfAdvert(value: unknown): value is ReceivedAgentSelfAdvert {
|
|
36
|
+
if (typeof value !== "object" || value === null) return false;
|
|
37
|
+
if (!("name" in value) || typeof value.name !== "string") return false;
|
|
38
|
+
// The name is the agent's self-asserted name, held to the rules it was registered under (requireAgentName); one parseDisplayName would not keep as it is did not come from a conforming peer and could carry a terminal control sequence.
|
|
39
|
+
if (parseDisplayName(value.name) !== value.name) return false;
|
|
40
|
+
if (!("harness" in value) || typeof value.harness !== "string") return false;
|
|
41
|
+
if ("membership" in value && typeof value.membership !== "string")
|
|
42
|
+
return false;
|
|
43
|
+
if ("machine" in value && typeof value.machine !== "string") return false;
|
|
44
|
+
if ("machineName" in value && typeof value.machineName !== "string")
|
|
45
|
+
return false;
|
|
46
|
+
if ("principalName" in value && typeof value.principalName !== "string")
|
|
47
|
+
return false;
|
|
48
|
+
if ("cwd" in value && typeof value.cwd !== "string") return false;
|
|
49
|
+
if ("pid" in value && typeof value.pid !== "number") return false;
|
|
50
|
+
if ("startedAt" in value && typeof value.startedAt !== "string") return false;
|
|
51
|
+
if ("tags" in value && !Array.isArray(value.tags)) return false;
|
|
52
|
+
if ("subscribedRooms" in value && !Array.isArray(value.subscribedRooms))
|
|
53
|
+
return false;
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class AgentRegistry {
|
|
58
|
+
constructor(private readonly deps: AgentRegistryDeps) {}
|
|
59
|
+
|
|
60
|
+
// -----------------------------------------------------------------------
|
|
61
|
+
// CommsStore — Identity
|
|
62
|
+
// -----------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
async readIdentity(
|
|
65
|
+
harness: string,
|
|
66
|
+
cwd: string,
|
|
67
|
+
): Promise<{ id: string } | undefined> {
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
return this.deps.identityCache.get(`${harness}--${cwd}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async writeIdentity(harness: string, cwd: string, id: string): Promise<void> {
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
this.deps.identityCache.set(`${harness}--${cwd}`, { id });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// -----------------------------------------------------------------------
|
|
78
|
+
// CommsStore — Agent registry
|
|
79
|
+
// -----------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
async registerAgent(opts: {
|
|
82
|
+
name: string;
|
|
83
|
+
harness: string;
|
|
84
|
+
cwd: string;
|
|
85
|
+
pid: number;
|
|
86
|
+
visibility: Visibility;
|
|
87
|
+
tags: string[];
|
|
88
|
+
}): Promise<AgentIdentity> {
|
|
89
|
+
const name = requireAgentName(opts.name);
|
|
90
|
+
const existing = await this.readIdentity(opts.harness, opts.cwd);
|
|
91
|
+
if (existing) {
|
|
92
|
+
return this.updateAgent(existing.id, {
|
|
93
|
+
name,
|
|
94
|
+
visibility: opts.visibility,
|
|
95
|
+
tags: opts.tags,
|
|
96
|
+
status: "active",
|
|
97
|
+
pid: opts.pid,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const id = this.deps.getPeerId();
|
|
102
|
+
const agent: AgentIdentity = {
|
|
103
|
+
id,
|
|
104
|
+
version: 1,
|
|
105
|
+
name,
|
|
106
|
+
harness: opts.harness,
|
|
107
|
+
cwd: opts.cwd,
|
|
108
|
+
pid: opts.pid,
|
|
109
|
+
startedAt: this.deps.startedAt,
|
|
110
|
+
visibility: opts.visibility,
|
|
111
|
+
status: "active",
|
|
112
|
+
tags: opts.tags,
|
|
113
|
+
subscribedRooms: [],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
this.deps.agents.set(id, agent);
|
|
117
|
+
await this.writeIdentity(opts.harness, opts.cwd, id);
|
|
118
|
+
await this.deps.deliveryEngine.broadcastPatch({
|
|
119
|
+
type: "agent_upsert",
|
|
120
|
+
agent,
|
|
121
|
+
});
|
|
122
|
+
return agent;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolves an agent by id, falling back to a gossip-discovered device (see listDiscoverableAgents' own doc) not otherwise locally known -- the same merge listAgents already applies to its returned array, but for a single lookup rather than the whole list. This is what lets a caller resolve a remote, hub-learned agent (agent-comms#155) that will never appear in deps.agents at all, since nothing replicates full agent records cross-machine the way a local peer's agent_upsert broadcast does. */
|
|
126
|
+
async getAgent(id: string): Promise<AgentIdentity | undefined> {
|
|
127
|
+
await Promise.resolve();
|
|
128
|
+
const known = this.deps.agents.get(id);
|
|
129
|
+
if (known !== undefined) return known;
|
|
130
|
+
const discovered = this.listDiscoverableAgents().find(
|
|
131
|
+
(candidate) => candidate.deviceId === id,
|
|
132
|
+
);
|
|
133
|
+
return discovered === undefined
|
|
134
|
+
? undefined
|
|
135
|
+
: AgentRegistry.synthesiseDiscoveredAgent(discovered);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async updateAgent(
|
|
139
|
+
id: string,
|
|
140
|
+
patch: Partial<
|
|
141
|
+
Pick<AgentIdentity, "name" | "visibility" | "status" | "tags" | "pid">
|
|
142
|
+
>,
|
|
143
|
+
): Promise<AgentIdentity> {
|
|
144
|
+
const agent = this.deps.agents.get(id);
|
|
145
|
+
if (!agent)
|
|
146
|
+
throw new CommsError(`Agent ${id} not found`, "AGENT_NOT_FOUND");
|
|
147
|
+
|
|
148
|
+
const name =
|
|
149
|
+
patch.name === undefined ? undefined : requireAgentName(patch.name);
|
|
150
|
+
const oldStatus = agent.status;
|
|
151
|
+
const oldName = agent.name;
|
|
152
|
+
const updatedAgent: AgentIdentity = {
|
|
153
|
+
...agent,
|
|
154
|
+
...patch,
|
|
155
|
+
...(name === undefined ? {} : { name }),
|
|
156
|
+
};
|
|
157
|
+
this.deps.deliveryEngine.bump(updatedAgent);
|
|
158
|
+
this.deps.agents.set(id, updatedAgent);
|
|
159
|
+
await this.deps.deliveryEngine.broadcastPatch({
|
|
160
|
+
type: "agent_upsert",
|
|
161
|
+
agent: updatedAgent,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (name !== undefined && name !== oldName) {
|
|
165
|
+
await this.deps.deliveryEngine.notifyRoomsOfNameChange(id, oldName, name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (patch.status && patch.status !== oldStatus) {
|
|
169
|
+
await this.deps.deliveryEngine.notifyRoomsOfStatus(id, patch.status);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return updatedAgent;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async listAgents(requesterId: string): Promise<AgentIdentity[]> {
|
|
176
|
+
await Promise.resolve();
|
|
177
|
+
const result: AgentIdentity[] = [];
|
|
178
|
+
for (const agent of this.deps.agents.values()) {
|
|
179
|
+
if (agent.visibility === "ghost" && agent.id !== requesterId) continue;
|
|
180
|
+
result.push(agent);
|
|
181
|
+
}
|
|
182
|
+
for (const discovered of this.listDiscoverableAgents()) {
|
|
183
|
+
if (this.deps.agents.has(discovered.deviceId)) continue;
|
|
184
|
+
result.push(AgentRegistry.synthesiseDiscoveredAgent(discovered));
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Builds the placeholder-shaped AgentIdentity a gossip-discovered device (never locally registered) is represented as -- shared by listAgents' own array merge and getAgent's single-lookup fallback, so both resolve an identical shape for the identical discovered device. */
|
|
190
|
+
private static synthesiseDiscoveredAgent(discovered: {
|
|
191
|
+
deviceId: string;
|
|
192
|
+
advert: ReceivedAgentSelfAdvert;
|
|
193
|
+
status: AgentStatus | undefined;
|
|
194
|
+
}): AgentIdentity {
|
|
195
|
+
// A sender that reached this side through a hub shares only its summary, so the details it withheld stay empty here. An empty cwd is what marks an agent whose details are not shared (see detailsShared); a local agent always has one.
|
|
196
|
+
return {
|
|
197
|
+
id: discovered.deviceId,
|
|
198
|
+
version: 0,
|
|
199
|
+
name: discovered.advert.name,
|
|
200
|
+
harness: discovered.advert.harness,
|
|
201
|
+
cwd: discovered.advert.cwd ?? "",
|
|
202
|
+
pid: discovered.advert.pid ?? 0,
|
|
203
|
+
startedAt: discovered.advert.startedAt ?? "",
|
|
204
|
+
visibility: "visible",
|
|
205
|
+
status: discovered.status ?? "active",
|
|
206
|
+
tags: discovered.advert.tags ?? [],
|
|
207
|
+
subscribedRooms: discovered.advert.subscribedRooms ?? [],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Every agent this store has heard gossiped by another device but never registered or otherwise locally recorded -- the read half of P3.8's eventual agent register/update/offline retirement (agent-comms#48), mirroring listRooms' own room-discovery merge (#138). Never merged into this.deps.agents: a gossip hint is not the same as a real registration, and this store has nothing else authoritative to report for it. Only ever an agent that gossiped itself as "visible" (MeshStore's own selfAgentAdvert getter never advertises a hidden or ghost agent this way), so no ghost-filtering is needed here the way listAgents' own local-agent check needs.
|
|
213
|
+
*
|
|
214
|
+
* A gossip-discovered device is never told its own advert has gone stale (agent-comms#301): nothing on the hub relays a lost connection to anyone but the two peers that held it, so the only signal this side has at all is whether the device's own last advert is still recent. A device whose snapshot-seconds is older than deps.presenceStaleAfterMs is reported offline regardless of whatever presence value that stale advert carried -- trusting a heartbeat that stopped arriving minutes ago is worse than reporting the honest "we don't know" that offline actually means here.
|
|
215
|
+
*/
|
|
216
|
+
private listDiscoverableAgents(): readonly {
|
|
217
|
+
deviceId: string;
|
|
218
|
+
advert: ReceivedAgentSelfAdvert;
|
|
219
|
+
status: AgentStatus | undefined;
|
|
220
|
+
}[] {
|
|
221
|
+
const transport = this.deps.requireTransport();
|
|
222
|
+
if (transport.listKnownDevices === undefined) return [];
|
|
223
|
+
const result: {
|
|
224
|
+
deviceId: string;
|
|
225
|
+
advert: ReceivedAgentSelfAdvert;
|
|
226
|
+
status: AgentStatus | undefined;
|
|
227
|
+
}[] = [];
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
for (const { deviceId, advert } of transport.listKnownDevices()) {
|
|
230
|
+
const candidate = advert["agent/self"];
|
|
231
|
+
if (!isAgentSelfAdvert(candidate)) continue;
|
|
232
|
+
result.push({
|
|
233
|
+
deviceId,
|
|
234
|
+
advert: candidate,
|
|
235
|
+
status: this.presenceStatusFor(advert, now),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The status listDiscoverableAgents reports for one gossip-discovered device's own advert: offline once its snapshot-seconds is older than deps.presenceStaleAfterMs, whatever presence/status it last carried; the gossiped presence/status value when the advert is still fresh and carries one; undefined when it's fresh but never gossiped presence at all, leaving synthesiseDiscoveredAgent's own `?? "active"` to report the same "reachable, no status opinion" default this had before agent-comms#301. snapshot-seconds missing or not a finite number (an advert this malformed should not reach here in practice, since every real self-advert is signed and schema-validated before this side ever merges it, but advert itself is an untrusted `Record<string, unknown>` as far as this function's own types are concerned) leaves staleness unjudged rather than guessed at either extreme. */
|
|
242
|
+
private presenceStatusFor(
|
|
243
|
+
advert: Readonly<Record<string, unknown>>,
|
|
244
|
+
now: number,
|
|
245
|
+
): AgentStatus | undefined {
|
|
246
|
+
const snapshotSeconds = advert["snapshot-seconds"];
|
|
247
|
+
if (
|
|
248
|
+
typeof snapshotSeconds === "number" &&
|
|
249
|
+
Number.isFinite(snapshotSeconds)
|
|
250
|
+
) {
|
|
251
|
+
const ageMs = now - snapshotSeconds * MS_PER_SECOND;
|
|
252
|
+
if (ageMs > this.deps.presenceStaleAfterMs) return "offline";
|
|
253
|
+
}
|
|
254
|
+
const status = advert["presence/status"];
|
|
255
|
+
return AgentStatus.is(status) ? status : undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async setAgentOffline(id: string): Promise<void> {
|
|
259
|
+
const agent = this.deps.agents.get(id);
|
|
260
|
+
if (!agent) return;
|
|
261
|
+
if (agent.status === "offline") return;
|
|
262
|
+
|
|
263
|
+
// Only the owning store should broadcast the status change. Other stores learn about it via the agent_offline mesh patch.
|
|
264
|
+
const isOwner = id === this.deps.getPeerId();
|
|
265
|
+
agent.status = "offline";
|
|
266
|
+
this.deps.deliveryEngine.bump(agent);
|
|
267
|
+
this.deps.agents.set(id, agent);
|
|
268
|
+
|
|
269
|
+
if (isOwner) {
|
|
270
|
+
await this.deps.deliveryEngine.notifyRoomsOfStatus(id, "offline");
|
|
271
|
+
await this.deps.deliveryEngine.broadcastPatch({
|
|
272
|
+
type: "agent_offline",
|
|
273
|
+
agentId: id,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Whether an agent's working directory, rooms and the rest were shared with this side. A gossip-discovered agent that reached us through a hub shares none of them, and is recorded with an empty cwd. */
|
|
280
|
+
export function detailsShared(agent: Readonly<AgentIdentity>): boolean {
|
|
281
|
+
return agent.cwd !== "";
|
|
282
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list_agents' aligned table (agent-comms#345). Every column but the last is as wide as its widest cell, header included, so the columns stay lined up under the header whatever the names are: a petname beside a quoted self name can be far longer than a bare registered name used to be, and a fixed width let one long name push every later column out of line.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** One agent's cells, in column order. cwd is last and so never padded. */
|
|
6
|
+
export interface AgentTableRow {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
harness: string;
|
|
10
|
+
status: string;
|
|
11
|
+
visibility: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const HEADER: Readonly<AgentTableRow> = {
|
|
16
|
+
id: "ID",
|
|
17
|
+
name: "Name",
|
|
18
|
+
harness: "Harness",
|
|
19
|
+
status: "Status",
|
|
20
|
+
visibility: "Visibility",
|
|
21
|
+
cwd: "CWD",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Every column but cwd, in display order. */
|
|
25
|
+
const PADDED_COLUMNS = [
|
|
26
|
+
"id",
|
|
27
|
+
"name",
|
|
28
|
+
"harness",
|
|
29
|
+
"status",
|
|
30
|
+
"visibility",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
type PaddedColumn = (typeof PADDED_COLUMNS)[number];
|
|
34
|
+
|
|
35
|
+
/** Between two columns: wider than the single space inside a cell such as a petname followed by a quoted self name, so a column boundary always reads as one. */
|
|
36
|
+
const COLUMN_GAP = " ";
|
|
37
|
+
|
|
38
|
+
/** The table over rows: its header line, and the line for any one of rows, each aligned against every row and the header. */
|
|
39
|
+
export function agentTable(rows: readonly Readonly<AgentTableRow>[]): {
|
|
40
|
+
header: string;
|
|
41
|
+
line: (row: Readonly<AgentTableRow>) => string;
|
|
42
|
+
} {
|
|
43
|
+
const widthOf = (column: PaddedColumn): number =>
|
|
44
|
+
Math.max(...[HEADER, ...rows].map((row) => row[column].length));
|
|
45
|
+
const widths: Readonly<Record<PaddedColumn, number>> = {
|
|
46
|
+
id: widthOf("id"),
|
|
47
|
+
name: widthOf("name"),
|
|
48
|
+
harness: widthOf("harness"),
|
|
49
|
+
status: widthOf("status"),
|
|
50
|
+
visibility: widthOf("visibility"),
|
|
51
|
+
};
|
|
52
|
+
const line = (row: Readonly<AgentTableRow>): string =>
|
|
53
|
+
[
|
|
54
|
+
...PADDED_COLUMNS.map((column) => row[column].padEnd(widths[column])),
|
|
55
|
+
row.cwd,
|
|
56
|
+
].join(COLUMN_GAP);
|
|
57
|
+
return { header: line(HEADER), line };
|
|
58
|
+
}
|