@haywoodmarx/agent-pager 0.1.2 → 0.2.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/README.md +5 -5
- package/dist/src/broker/address.d.ts +7 -0
- package/dist/src/broker/address.js +9 -0
- package/dist/src/broker/broker.d.ts +91 -23
- package/dist/src/broker/broker.js +505 -123
- package/dist/src/broker/operator-projection.js +3 -5
- package/dist/src/broker/protocol.d.ts +1 -1
- package/dist/src/broker/protocol.js +1 -1
- package/dist/src/broker/schema.d.ts +4 -0
- package/dist/src/broker/schema.js +9 -6
- package/dist/src/broker/snapshot.d.ts +4 -2
- package/dist/src/implementations/claude/gateway-cli.js +1 -2
- package/dist/src/implementations/claude/gateway.d.ts +11 -4
- package/dist/src/implementations/claude/gateway.js +44 -14
- package/dist/src/implementations/claude/instructions.js +1 -1
- package/dist/src/implementations/model.d.ts +6 -3
- package/dist/src/implementations/opencode/plugin.d.ts +7 -2
- package/dist/src/implementations/opencode/plugin.js +103 -7
- package/dist/src/implementations/pi/broker-client.d.ts +13 -4
- package/dist/src/implementations/pi/broker-client.js +71 -9
- package/dist/src/implementations/pi/extension.d.ts +7 -2
- package/dist/src/implementations/pi/extension.js +15 -3
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/launch/broker-lock.d.ts +64 -4
- package/dist/src/launch/broker-lock.js +149 -7
- package/dist/src/launch/cli.js +5 -2
- package/dist/src/launch/launch.d.ts +1 -1
- package/dist/src/launch/on-demand.d.ts +8 -8
- package/dist/src/launch/on-demand.js +54 -35
- package/dist/src/launch/runtime-composition.js +2 -0
- package/dist/src/launch/runtime.js +4 -14
- package/dist/src/launch/service.d.ts +1 -0
- package/dist/src/launch/service.js +23 -1
- package/dist/src/launch/workspace-branch.d.ts +3 -3
- package/dist/src/launch/workspace-branch.js +7 -15
- package/dist/src/launch/workspace-root.d.ts +13 -0
- package/dist/src/launch/workspace-root.js +27 -0
- package/dist/src/messaging/delivery-client.d.ts +17 -0
- package/dist/src/messaging/delivery-client.js +31 -1
- package/dist/src/messaging/identity-statement.d.ts +7 -0
- package/dist/src/messaging/identity-statement.js +8 -0
- package/dist/src/messaging/mcp.d.ts +8 -2
- package/dist/src/messaging/mcp.js +54 -1
- package/dist/src/messaging/notifications.d.ts +31 -3
- package/dist/src/messaging/notifications.js +25 -0
- package/dist/src/messaging/push-delivery.d.ts +2 -1
- package/dist/src/messaging/push-delivery.js +24 -7
- package/dist/src/messaging/tools.js +22 -2
- package/dist/src/messaging/wake.js +7 -0
- package/dist/src/operator/agent-projection.d.ts +8 -5
- package/dist/src/operator/agent-projection.js +29 -9
- package/dist/src/operator/protocol.d.ts +4 -2
- package/dist/src/operator/repository-system.d.ts +29 -0
- package/dist/src/operator/repository-system.js +39 -12
- package/dist/src/operator/repository.d.ts +28 -11
- package/dist/src/operator/repository.js +37 -4
- package/dist/src/operator/updates.js +2 -0
- package/dist/src/operator/validation.js +11 -1
- package/dist/src/shared/versions.d.ts +18 -0
- package/dist/src/shared/versions.js +26 -0
- package/dist/src/tui/agent-tree.js +2 -2
- package/dist/src/tui/app.js +3 -2
- package/dist/src/tui/model.js +1 -1
- package/dist/src/version.d.ts +7 -0
- package/dist/src/version.js +14 -6
- package/package.json +1 -1
- package/skills/pager/SKILL.md +35 -45
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { ProtocolError } from "../broker/protocol.js";
|
|
7
7
|
import { resolveBunExecutable } from "./runtime.js";
|
|
8
|
-
import { brokerLockHasCurrentIdentity, brokerLockIsLive, brokerLockPath, isCurrentBrokerServiceIdentity, processIsAlive, processStartedAt, readBrokerLock, startTimeStillMatches, readBrokerOwner,
|
|
8
|
+
import { BROKER_EXIT_POLL_MS, BROKER_EXIT_TIMEOUT_MS, BrokerServiceExitTimeoutError, brokerLockHasCurrentIdentity, brokerLockIsLive, brokerLockIsReplaceable, brokerLockPath, isCurrentBrokerServiceIdentity, processIsAlive, processStartedAt, readBrokerLock, startTimeStillMatches, readBrokerOwner, SERVICE_BUILD_ID, } from "./broker-lock.js";
|
|
9
9
|
/** Bounds one loopback endpoint probe, which either answers promptly or is treated as unreachable. */
|
|
10
10
|
export const BROKER_PROBE_TIMEOUT_MS = 2_000;
|
|
11
11
|
/**
|
|
@@ -99,17 +99,6 @@ export class BrokerServiceUnreachableError extends ProtocolError {
|
|
|
99
99
|
this.name = "BrokerServiceUnreachableError";
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
-
/** Named failure raised when a signalled broker service is still running once the exit budget is spent. */
|
|
103
|
-
export class BrokerServiceExitTimeoutError extends ProtocolError {
|
|
104
|
-
/**
|
|
105
|
-
* Construct the failure reported when the owner ignores or outlasts its termination signal.
|
|
106
|
-
* @param pid - Process id of the owner that was signalled.
|
|
107
|
-
*/
|
|
108
|
-
constructor(pid) {
|
|
109
|
-
super("validation_failed", `broker service pid ${String(pid)} was signalled to exit and is still running`);
|
|
110
|
-
this.name = "BrokerServiceExitTimeoutError";
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
102
|
/** Named failure raised when an upgrade encounters a live pre-identity service. */
|
|
114
103
|
export class LegacyBrokerServiceError extends ProtocolError {
|
|
115
104
|
/**
|
|
@@ -143,6 +132,9 @@ export function liveBrokerEndpoints(lock, isAlive, startedAt) {
|
|
|
143
132
|
}
|
|
144
133
|
/**
|
|
145
134
|
* Adopt a running broker service or start one detached and wait for its endpoints.
|
|
135
|
+
*
|
|
136
|
+
* Never signals a running service itself. A foreign owner that {@link brokerLockIsReplaceable}
|
|
137
|
+
* accepts is left for the started service to stop while it takes the lock; any other is refused.
|
|
146
138
|
* @param environment - Resolved configuration shared by the caller and service process.
|
|
147
139
|
* @param overrides - Optional process and timing seams for isolated verification.
|
|
148
140
|
* @returns - Live loopback endpoints owned by the singleton service process.
|
|
@@ -150,11 +142,10 @@ export function liveBrokerEndpoints(lock, isAlive, startedAt) {
|
|
|
150
142
|
export async function ensureBrokerService(environment, overrides = {}) {
|
|
151
143
|
const dependencies = { ...defaultDependencies(), ...overrides };
|
|
152
144
|
const lockPath = brokerLockPath(environment.dbPath);
|
|
153
|
-
const ownerPid = readBrokerOwnerPid(lockPath);
|
|
154
145
|
const candidate = readBrokerLock(lockPath);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
146
|
+
const refused = refusedForeignOwner(lockPath, readBrokerOwner(lockPath), candidate, dependencies);
|
|
147
|
+
if (refused !== undefined)
|
|
148
|
+
throw new LegacyBrokerServiceError(refused);
|
|
158
149
|
const existing = liveBrokerEndpoints(candidate, dependencies.processIsAlive, dependencies.processStartedAt);
|
|
159
150
|
if (existing) {
|
|
160
151
|
// The lock already proved this is the current build, so a probe that misses is a slow or
|
|
@@ -189,11 +180,12 @@ export async function ensureBrokerService(environment, overrides = {}) {
|
|
|
189
180
|
let unanswered;
|
|
190
181
|
try {
|
|
191
182
|
while (dependencies.now() <= deadline) {
|
|
192
|
-
const
|
|
183
|
+
const loopOwner = readBrokerOwner(lockPath);
|
|
184
|
+
const loopOwnerPid = loopOwner?.pid;
|
|
193
185
|
const loopCandidate = readBrokerLock(lockPath);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
186
|
+
const loopRefused = refusedForeignOwner(lockPath, loopOwner, loopCandidate, dependencies);
|
|
187
|
+
if (loopRefused !== undefined)
|
|
188
|
+
throw new LegacyBrokerServiceError(loopRefused);
|
|
197
189
|
const endpoints = liveBrokerEndpoints(loopCandidate, dependencies.processIsAlive, dependencies.processStartedAt);
|
|
198
190
|
unanswered = undefined;
|
|
199
191
|
if (endpoints) {
|
|
@@ -243,13 +235,6 @@ export async function ensureBrokerService(environment, overrides = {}) {
|
|
|
243
235
|
child.releaseOutput();
|
|
244
236
|
}
|
|
245
237
|
}
|
|
246
|
-
/**
|
|
247
|
-
* Bounds waiting for a signalled owner to exit. It must exceed `SHUTDOWN_GRACE_MS` in `cli.ts`, the
|
|
248
|
-
* time a stopping service allows its endpoints to close before it exits on its own.
|
|
249
|
-
*/
|
|
250
|
-
const BROKER_EXIT_TIMEOUT_MS = 10_000;
|
|
251
|
-
/** Spaces the liveness polls made while a signalled owner shuts down. */
|
|
252
|
-
const BROKER_EXIT_POLL_MS = 50;
|
|
253
238
|
/**
|
|
254
239
|
* Stop the broker service that owns the configured database, whichever build started it.
|
|
255
240
|
*
|
|
@@ -261,13 +246,10 @@ const BROKER_EXIT_POLL_MS = 50;
|
|
|
261
246
|
*/
|
|
262
247
|
export async function stopBrokerService(environment, overrides = {}) {
|
|
263
248
|
const dependencies = {
|
|
264
|
-
|
|
265
|
-
processStartedAt,
|
|
249
|
+
...systemProcessDependencies(),
|
|
266
250
|
signal: (pid, signal) => {
|
|
267
251
|
process.kill(pid, signal);
|
|
268
252
|
},
|
|
269
|
-
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
270
|
-
now: Date.now,
|
|
271
253
|
...overrides,
|
|
272
254
|
};
|
|
273
255
|
const owner = readBrokerOwner(brokerLockPath(environment.dbPath));
|
|
@@ -376,8 +358,21 @@ function spawnCapturingStderr(command, args, environment) {
|
|
|
376
358
|
},
|
|
377
359
|
};
|
|
378
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Build the operating-system process and timing seams shared by starting and stopping a service.
|
|
363
|
+
* @returns - Seams backed by the process table, timers and the wall clock.
|
|
364
|
+
*/
|
|
365
|
+
function systemProcessDependencies() {
|
|
366
|
+
return {
|
|
367
|
+
processIsAlive,
|
|
368
|
+
processStartedAt,
|
|
369
|
+
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
370
|
+
now: Date.now,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
379
373
|
function defaultDependencies() {
|
|
380
374
|
return {
|
|
375
|
+
...systemProcessDependencies(),
|
|
381
376
|
spawnService: spawnCapturingStderr,
|
|
382
377
|
report: (message) => {
|
|
383
378
|
process.stderr.write(message.endsWith("\n") ? message : `${message}\n`);
|
|
@@ -386,8 +381,7 @@ function defaultDependencies() {
|
|
|
386
381
|
command: resolveBunExecutable(environment.processEnv),
|
|
387
382
|
args: [fileURLToPath(new URL("./cli.js", import.meta.url)), "serve"],
|
|
388
383
|
}),
|
|
389
|
-
|
|
390
|
-
processStartedAt,
|
|
384
|
+
buildId: SERVICE_BUILD_ID,
|
|
391
385
|
endpointsReady: async ({ mcpUrl, snapshotUrl }) => {
|
|
392
386
|
let health;
|
|
393
387
|
let snapshot;
|
|
@@ -421,8 +415,6 @@ function defaultDependencies() {
|
|
|
421
415
|
return "unhealthy";
|
|
422
416
|
}
|
|
423
417
|
},
|
|
424
|
-
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
425
|
-
now: Date.now,
|
|
426
418
|
};
|
|
427
419
|
}
|
|
428
420
|
function adoptionRetryDelayMs(probes) {
|
|
@@ -433,3 +425,30 @@ function ownerStartTimeMatches(lock, startedAt) {
|
|
|
433
425
|
return true;
|
|
434
426
|
return startTimeStillMatches(lock.ownerStartedAt, startedAt(lock.pid));
|
|
435
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Name the live foreign owner this caller must refuse.
|
|
430
|
+
*
|
|
431
|
+
* An owner counts as live only when its process started at the time its lock records.
|
|
432
|
+
* @param lockPath - Lock file both records were read from, read again before refusing.
|
|
433
|
+
* @param owner - Owner read from any structurally valid lock, current or legacy.
|
|
434
|
+
* @param lock - The same file read as a lock record this build accepts, when it is one.
|
|
435
|
+
* @param dependencies - Process seams reading liveness and start times.
|
|
436
|
+
* @returns - The owner's pid when it must be refused, or undefined when it is absent, stale, current,
|
|
437
|
+
* or replaceable.
|
|
438
|
+
*/
|
|
439
|
+
function refusedForeignOwner(lockPath, owner, lock, dependencies) {
|
|
440
|
+
if (owner === undefined || !dependencies.processIsAlive(owner.pid))
|
|
441
|
+
return undefined;
|
|
442
|
+
const startedAt = dependencies.processStartedAt(owner.pid);
|
|
443
|
+
if (owner.startedAt !== undefined && !startTimeStillMatches(owner.startedAt, startedAt))
|
|
444
|
+
return undefined;
|
|
445
|
+
if (brokerLockHasCurrentIdentity(lock))
|
|
446
|
+
return undefined;
|
|
447
|
+
if (lock?.pid === owner.pid && brokerLockIsReplaceable(lock, startedAt, dependencies.buildId))
|
|
448
|
+
return undefined;
|
|
449
|
+
// An owner that another starting service is replacing can exit, or remove its lock, between the
|
|
450
|
+
// reads above, leaving no start time or record to judge. It is a departing owner, not a foreign one.
|
|
451
|
+
if (!dependencies.processIsAlive(owner.pid) || readBrokerOwner(lockPath)?.pid !== owner.pid)
|
|
452
|
+
return undefined;
|
|
453
|
+
return owner.pid;
|
|
454
|
+
}
|
|
@@ -15,6 +15,7 @@ import { discoverImplementationRegistry, implementationId, } from "../implementa
|
|
|
15
15
|
import { DirectTerminalProvider, ForegroundTerminalProvider, LaunchService, } from "./launch.js";
|
|
16
16
|
import { spawnForegroundProcess } from "./terminal.js";
|
|
17
17
|
import { systemWorkspaceBranch } from "./workspace-branch.js";
|
|
18
|
+
import { systemWorkspaceRoot } from "./workspace-root.js";
|
|
18
19
|
import { initializePagerStore } from "../configuration.js";
|
|
19
20
|
import { isPlainObject } from "../shared/guards.js";
|
|
20
21
|
export { readRuntimeEnvironment } from "./environment.js";
|
|
@@ -33,6 +34,7 @@ export function createBroker(environment) {
|
|
|
33
34
|
return new Broker(environment.dbPath, {
|
|
34
35
|
now: Date.now,
|
|
35
36
|
workspaceBranch: systemWorkspaceBranch,
|
|
37
|
+
workspaceRoot: systemWorkspaceRoot,
|
|
36
38
|
report: (message) => {
|
|
37
39
|
process.stderr.write(`${message}\n`);
|
|
38
40
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { accessSync, constants, realpathSync } from "node:fs";
|
|
3
3
|
import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { compareNumericVersions, parseNumericVersion } from "../shared/versions.js";
|
|
4
5
|
import { readRuntimeEnvironment } from "./environment.js";
|
|
5
6
|
/** Bounds one probe of an external runtime executable. */
|
|
6
7
|
const RUNTIME_PROBE_TIMEOUT_MS = 10_000;
|
|
@@ -32,22 +33,11 @@ export class BunRuntimeError extends Error {
|
|
|
32
33
|
* @returns - True when the version is at or above the supported floor.
|
|
33
34
|
*/
|
|
34
35
|
export function satisfiesBunVersionFloor(version) {
|
|
35
|
-
const candidate =
|
|
36
|
-
const minimum =
|
|
36
|
+
const candidate = parseNumericVersion(version, "prefix");
|
|
37
|
+
const minimum = parseNumericVersion(MINIMUM_BUN_VERSION, "prefix");
|
|
37
38
|
if (!candidate || !minimum)
|
|
38
39
|
return false;
|
|
39
|
-
|
|
40
|
-
const difference = (candidate[index] ?? 0) - (minimum[index] ?? 0);
|
|
41
|
-
if (difference !== 0)
|
|
42
|
-
return difference > 0;
|
|
43
|
-
}
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
function numericVersion(version) {
|
|
47
|
-
const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(version);
|
|
48
|
-
if (!match)
|
|
49
|
-
return undefined;
|
|
50
|
-
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
40
|
+
return compareNumericVersions(candidate, minimum) >= 0;
|
|
51
41
|
}
|
|
52
42
|
/**
|
|
53
43
|
* Fail fast when the current process is not a supported Bun runtime.
|
|
@@ -10,6 +10,7 @@ import { type OperatorTransportSchedulerPort } from "../operator/transport.js";
|
|
|
10
10
|
import { type OperatorUpdateSourcePort } from "../operator/updates.js";
|
|
11
11
|
import { type BrokerLockOwner } from "./broker-lock.js";
|
|
12
12
|
export { systemWorkspaceBranch } from "./workspace-branch.js";
|
|
13
|
+
export { systemWorkspaceRoot } from "./workspace-root.js";
|
|
13
14
|
/** Schedules the service's timers, none of which may keep the process alive. */
|
|
14
15
|
export interface BrokerServiceSchedulerPort extends OperatorTransportSchedulerPort {
|
|
15
16
|
/**
|
|
@@ -9,13 +9,16 @@ import { startMcpServer } from "../messaging/mcp.js";
|
|
|
9
9
|
import { NotificationHub, NotificationScheduler } from "../messaging/notifications.js";
|
|
10
10
|
import { PushDeliveryService } from "../messaging/push-delivery.js";
|
|
11
11
|
import { OperatorCommandService } from "../operator/commands.js";
|
|
12
|
+
import { rememberRepositoryInspections } from "../operator/repository.js";
|
|
12
13
|
import { createSystemRepositoryProjectionDependencies } from "../operator/repository-system.js";
|
|
13
14
|
import { issueOperatorClientId, startOperatorTransport, } from "../operator/transport.js";
|
|
14
15
|
import { SqliteOperatorEventStore } from "../operator/update-store.js";
|
|
15
16
|
import { OperatorUpdateService } from "../operator/updates.js";
|
|
16
17
|
import { currentBrokerServiceIdentity } from "./broker-lock.js";
|
|
17
18
|
import { systemWorkspaceBranch } from "./workspace-branch.js";
|
|
19
|
+
import { systemWorkspaceRoot } from "./workspace-root.js";
|
|
18
20
|
export { systemWorkspaceBranch } from "./workspace-branch.js";
|
|
21
|
+
export { systemWorkspaceRoot } from "./workspace-root.js";
|
|
19
22
|
/** Tables whose rows can move the moment a sweep next has work to do. */
|
|
20
23
|
const SWEEP_DEADLINE_TABLES = ["agents", "sessions", "messages", "wakes"];
|
|
21
24
|
/** Sets how often the watchdog checks that the database this service serves still exists. */
|
|
@@ -26,6 +29,10 @@ const OPERATOR_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
|
26
29
|
const OPERATOR_LIVENESS_TIMEOUT_MS = 15_000;
|
|
27
30
|
/** Sets the entropy of an operator control id, in bytes. */
|
|
28
31
|
const CONTROL_ID_BYTES = 12;
|
|
32
|
+
/** Bounds how long one directory's Git inspection stands in for a fresh one in operator projections. */
|
|
33
|
+
const REPOSITORY_INSPECTION_TTL_MS = 30_000;
|
|
34
|
+
/** Bounds each Git command the operator projection runs; one that runs longer is treated as having failed. */
|
|
35
|
+
const REPOSITORY_DISCOVERY_TIMEOUT_MS = 2_000;
|
|
29
36
|
/** Delays the retry after a sweep failed, so a persistent failure cannot spin on a due deadline. */
|
|
30
37
|
const SWEEP_FAILURE_RETRY_MS = 1_000;
|
|
31
38
|
/** Reports that a configured loopback endpoint could not take its port. */
|
|
@@ -137,6 +144,7 @@ export class BrokerService {
|
|
|
137
144
|
this.#broker = this.#options.openBroker(this.#databasePath, {
|
|
138
145
|
now,
|
|
139
146
|
workspaceBranch: systemWorkspaceBranch,
|
|
147
|
+
workspaceRoot: systemWorkspaceRoot,
|
|
140
148
|
report: this.#report,
|
|
141
149
|
...(config
|
|
142
150
|
? {
|
|
@@ -194,6 +202,13 @@ export class BrokerService {
|
|
|
194
202
|
forgetDeliveryAddress: (agentId) => {
|
|
195
203
|
pushDelivery.forget(agentId);
|
|
196
204
|
},
|
|
205
|
+
notifySessionClaimed: (claim) => {
|
|
206
|
+
notifications.publishSessionClaimed(claim.agentId, {
|
|
207
|
+
claimedAddress: claim.address,
|
|
208
|
+
claimantAddress: claim.retiredAddress,
|
|
209
|
+
claimantId: claim.retiredId,
|
|
210
|
+
});
|
|
211
|
+
},
|
|
197
212
|
},
|
|
198
213
|
host: bindHost,
|
|
199
214
|
healthIdentity: currentBrokerServiceIdentity(),
|
|
@@ -423,12 +438,19 @@ export class BrokerService {
|
|
|
423
438
|
const descriptors = new Map();
|
|
424
439
|
for (const descriptor of this.#implementations.descriptors())
|
|
425
440
|
descriptors.set(descriptor.id, descriptor);
|
|
441
|
+
const repositoryDiscovery = createSystemRepositoryProjectionDependencies({
|
|
442
|
+
gitTimeoutMs: REPOSITORY_DISCOVERY_TIMEOUT_MS,
|
|
443
|
+
});
|
|
426
444
|
const operatorUpdates = new OperatorUpdateService({
|
|
427
445
|
source: broker,
|
|
428
446
|
store,
|
|
429
447
|
now: this.#now,
|
|
430
448
|
projection: {
|
|
431
|
-
|
|
449
|
+
fileSystem: repositoryDiscovery.fileSystem,
|
|
450
|
+
git: rememberRepositoryInspections(repositoryDiscovery.git, {
|
|
451
|
+
now: this.#now,
|
|
452
|
+
ttlMs: REPOSITORY_INSPECTION_TTL_MS,
|
|
453
|
+
}),
|
|
432
454
|
implementations: {
|
|
433
455
|
find: (implementationId) => {
|
|
434
456
|
const descriptor = descriptors.get(implementationId);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Report the branch checked out at
|
|
2
|
+
* Report the branch checked out at an agent's working directory.
|
|
3
3
|
*
|
|
4
4
|
* A standalone module so a caller composing only the broker itself avoids pulling in the whole
|
|
5
5
|
* `BrokerService` module graph for one `git` lookup.
|
|
6
|
-
* @param path - Absolute
|
|
6
|
+
* @param path - Absolute working directory to inspect.
|
|
7
7
|
* @returns - Current branch name, or null when the path is not a repository, is detached, or the
|
|
8
|
-
* lookup did not answer within {@link WORKSPACE_BRANCH_TIMEOUT_MS}.
|
|
8
|
+
* lookup failed or did not answer within {@link WORKSPACE_BRANCH_TIMEOUT_MS}.
|
|
9
9
|
*/
|
|
10
10
|
export declare function systemWorkspaceBranch(path: string): string | null;
|
|
@@ -1,27 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
* Bounds the branch lookup, which runs a subprocess against a path this process does not control.
|
|
4
|
-
*
|
|
5
|
-
* The branch is a label on a workspace row; nothing waits on it, so a slow answer is discarded
|
|
6
|
-
* rather than waited for.
|
|
7
|
-
*/
|
|
1
|
+
import { runGit } from "../operator/repository-system.js";
|
|
2
|
+
/** Bounds the branch lookup; a lookup that runs longer is treated as having failed. */
|
|
8
3
|
const WORKSPACE_BRANCH_TIMEOUT_MS = 2_000;
|
|
9
4
|
/**
|
|
10
|
-
* Report the branch checked out at
|
|
5
|
+
* Report the branch checked out at an agent's working directory.
|
|
11
6
|
*
|
|
12
7
|
* A standalone module so a caller composing only the broker itself avoids pulling in the whole
|
|
13
8
|
* `BrokerService` module graph for one `git` lookup.
|
|
14
|
-
* @param path - Absolute
|
|
9
|
+
* @param path - Absolute working directory to inspect.
|
|
15
10
|
* @returns - Current branch name, or null when the path is not a repository, is detached, or the
|
|
16
|
-
* lookup did not answer within {@link WORKSPACE_BRANCH_TIMEOUT_MS}.
|
|
11
|
+
* lookup failed or did not answer within {@link WORKSPACE_BRANCH_TIMEOUT_MS}.
|
|
17
12
|
*/
|
|
18
13
|
export function systemWorkspaceBranch(path) {
|
|
19
14
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
23
|
-
timeout: WORKSPACE_BRANCH_TIMEOUT_MS,
|
|
24
|
-
}).trim() || null);
|
|
15
|
+
const result = runGit("git", ["-C", path, "branch", "--show-current"], WORKSPACE_BRANCH_TIMEOUT_MS);
|
|
16
|
+
return result.kind === "ok" ? result.stdout.trim() || null : null;
|
|
25
17
|
}
|
|
26
18
|
catch {
|
|
27
19
|
return null;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report the directory that names the workspace a working directory belongs to.
|
|
3
|
+
*
|
|
4
|
+
* Inside a Git repository that is one directory for every worktree of the repository, so the main
|
|
5
|
+
* worktree, every linked worktree, and every subdirectory of any of them share one workspace: the
|
|
6
|
+
* directory holding the repository's `.git` or `.bare` directory.
|
|
7
|
+
* @param path - Absolute working directory to inspect.
|
|
8
|
+
* @returns - Canonical directory naming the repository containing the path, or the path itself when
|
|
9
|
+
* it is not inside a repository, its Git directory is not named `.git` or `.bare`, or it does not exist.
|
|
10
|
+
* @throws {RepositoryDiscoveryError} When Git cannot answer: it is missing, failed, or did not answer
|
|
11
|
+
* within {@link WORKSPACE_ROOT_TIMEOUT_MS}.
|
|
12
|
+
*/
|
|
13
|
+
export declare function systemWorkspaceRoot(path: string): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import { discoverRepositoryCommonDirectory } from "../operator/repository-system.js";
|
|
4
|
+
/** Bounds the repository lookup; a lookup that runs longer is treated as having failed. */
|
|
5
|
+
const WORKSPACE_ROOT_TIMEOUT_MS = 2_000;
|
|
6
|
+
/** Git directory names whose containing directory is the checkout or project they belong to. */
|
|
7
|
+
const REPOSITORY_DIRECTORY_NAMES = new Set([".git", ".bare"]);
|
|
8
|
+
/**
|
|
9
|
+
* Report the directory that names the workspace a working directory belongs to.
|
|
10
|
+
*
|
|
11
|
+
* Inside a Git repository that is one directory for every worktree of the repository, so the main
|
|
12
|
+
* worktree, every linked worktree, and every subdirectory of any of them share one workspace: the
|
|
13
|
+
* directory holding the repository's `.git` or `.bare` directory.
|
|
14
|
+
* @param path - Absolute working directory to inspect.
|
|
15
|
+
* @returns - Canonical directory naming the repository containing the path, or the path itself when
|
|
16
|
+
* it is not inside a repository, its Git directory is not named `.git` or `.bare`, or it does not exist.
|
|
17
|
+
* @throws {RepositoryDiscoveryError} When Git cannot answer: it is missing, failed, or did not answer
|
|
18
|
+
* within {@link WORKSPACE_ROOT_TIMEOUT_MS}.
|
|
19
|
+
*/
|
|
20
|
+
export function systemWorkspaceRoot(path) {
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return path;
|
|
23
|
+
const commonDirectory = discoverRepositoryCommonDirectory(path, { gitTimeoutMs: WORKSPACE_ROOT_TIMEOUT_MS });
|
|
24
|
+
if (commonDirectory === null)
|
|
25
|
+
return path;
|
|
26
|
+
return REPOSITORY_DIRECTORY_NAMES.has(basename(commonDirectory)) ? dirname(commonDirectory) : path;
|
|
27
|
+
}
|
|
@@ -90,6 +90,23 @@ export declare class BrokerDeliveryClient {
|
|
|
90
90
|
*/
|
|
91
91
|
call(tool: string, args: Record<string, unknown>): Promise<unknown>;
|
|
92
92
|
}
|
|
93
|
+
/** A failure the broker answered with its own error code. */
|
|
94
|
+
export declare class BrokerRefusal extends Error {
|
|
95
|
+
/** The broker's error code, such as `expired`. */
|
|
96
|
+
readonly code: string;
|
|
97
|
+
/**
|
|
98
|
+
* Construct a refusal carrying the broker's message and code.
|
|
99
|
+
* @param message - The broker's own explanation.
|
|
100
|
+
* @param code - The broker's error code for the refusal.
|
|
101
|
+
*/
|
|
102
|
+
constructor(message: string, code: string);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Report whether a failure is the broker refusing the session presented as expired.
|
|
106
|
+
* @param error - Rejection from a broker request.
|
|
107
|
+
* @returns - True only for a refusal the broker coded `expired`.
|
|
108
|
+
*/
|
|
109
|
+
export declare function isExpiredRefusal(error: unknown): boolean;
|
|
93
110
|
/**
|
|
94
111
|
* Build one compatibility-path tool call, without session headers.
|
|
95
112
|
* @param tool - Tool the broker dispatches on; connector-only tools are accepted on this path.
|
|
@@ -118,6 +118,29 @@ export class BrokerDeliveryClient {
|
|
|
118
118
|
return compatibilityPayload(response, `Broker ${tool}`);
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
|
+
/** A failure the broker answered with its own error code. */
|
|
122
|
+
export class BrokerRefusal extends Error {
|
|
123
|
+
/** The broker's error code, such as `expired`. */
|
|
124
|
+
code;
|
|
125
|
+
/**
|
|
126
|
+
* Construct a refusal carrying the broker's message and code.
|
|
127
|
+
* @param message - The broker's own explanation.
|
|
128
|
+
* @param code - The broker's error code for the refusal.
|
|
129
|
+
*/
|
|
130
|
+
constructor(message, code) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = "BrokerRefusal";
|
|
133
|
+
this.code = code;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Report whether a failure is the broker refusing the session presented as expired.
|
|
138
|
+
* @param error - Rejection from a broker request.
|
|
139
|
+
* @returns - True only for a refusal the broker coded `expired`.
|
|
140
|
+
*/
|
|
141
|
+
export function isExpiredRefusal(error) {
|
|
142
|
+
return error instanceof BrokerRefusal && error.code === "expired";
|
|
143
|
+
}
|
|
121
144
|
/**
|
|
122
145
|
* Build one compatibility-path tool call, without session headers.
|
|
123
146
|
* @param tool - Tool the broker dispatches on; connector-only tools are accepted on this path.
|
|
@@ -227,7 +250,14 @@ async function parseEnvelopeBody(response, operation) {
|
|
|
227
250
|
function envelopeFailure(response, payload, operation) {
|
|
228
251
|
const error = asRecord(payload?.error);
|
|
229
252
|
const message = typeof error?.message === "string" ? error.message : `${operation} failed (${String(response.status)})`;
|
|
230
|
-
|
|
253
|
+
const code = refusalCode(error);
|
|
254
|
+
return code === undefined ? new Error(message) : new BrokerRefusal(message, code);
|
|
255
|
+
}
|
|
256
|
+
function refusalCode(error) {
|
|
257
|
+
const rpcData = asRecord(error?.data);
|
|
258
|
+
if (typeof rpcData?.code === "string")
|
|
259
|
+
return rpcData.code;
|
|
260
|
+
return typeof error?.code === "string" ? error.code : undefined;
|
|
231
261
|
}
|
|
232
262
|
/**
|
|
233
263
|
* Accept an object carrying only permitted fields, rejecting anything additional.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AgentIdentity } from "../broker/broker.js";
|
|
2
|
+
/**
|
|
3
|
+
* State the identity a session acts as, in the words the broker uses for it.
|
|
4
|
+
* @param identity - Identity as `whoami` reports it.
|
|
5
|
+
* @returns - Text naming the alias, workspace, address and agent id, and nothing else.
|
|
6
|
+
*/
|
|
7
|
+
export declare function agentIdentityStatement(identity: AgentIdentity): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State the identity a session acts as, in the words the broker uses for it.
|
|
3
|
+
* @param identity - Identity as `whoami` reports it.
|
|
4
|
+
* @returns - Text naming the alias, workspace, address and agent id, and nothing else.
|
|
5
|
+
*/
|
|
6
|
+
export function agentIdentityStatement(identity) {
|
|
7
|
+
return `You are ${identity.alias} in workspace ${identity.workspace}, reachable from any workspace at ${identity.address}. Your agent id is ${identity.id}.`;
|
|
8
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Broker } from "../broker/broker.js";
|
|
1
|
+
import type { AliasClaim, Broker } from "../broker/broker.js";
|
|
2
2
|
import { type AgentId } from "../broker/protocol.js";
|
|
3
3
|
import type { NotificationScheduler } from "./notifications.js";
|
|
4
4
|
/** Supplies notification and push-address operations to the MCP transport. */
|
|
@@ -17,11 +17,17 @@ export type McpNotificationPort = {
|
|
|
17
17
|
* @param agentId - Identity whose address is no longer answered.
|
|
18
18
|
*/
|
|
19
19
|
forgetDeliveryAddress?: (agentId: AgentId) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Tells the process displaced from an identity that another process took the identity over,
|
|
22
|
+
* at the address that process registered for it. Called after the takeover committed.
|
|
23
|
+
* @param claim - The identity taken and what the claiming process gave up.
|
|
24
|
+
*/
|
|
25
|
+
notifySessionClaimed?: (claim: AliasClaim) => void;
|
|
20
26
|
};
|
|
21
27
|
/** Names the request header an attached session presents its host delivery address in. */
|
|
22
28
|
export declare const DELIVERY_ADDRESS_HEADER = "x-pager-delivery-address";
|
|
23
29
|
/** Broker operations the MCP server reaches. */
|
|
24
|
-
export type McpBrokerPort = Pick<Broker, "ack" | "adoptSessionKey" | "agentIdentity" | "attach" | "broadcast" | "fetch" | "listAgents" | "listTeamMembers" | "registerAgentWithoutReservation" | "releaseAttachments" | "reserveAttachment" | "resolveSession" | "resumeSession" | "send" | "sessionLeaseTtlMs" | "status"
|
|
30
|
+
export type McpBrokerPort = Pick<Broker, "ack" | "adoptSessionKey" | "agentIdentity" | "attach" | "broadcast" | "fetch" | "listAgents" | "listTeamMembers" | "registerAgentWithoutReservation" | "releaseAttachments" | "relocateAgent" | "reserveAttachment" | "resolveSession" | "resumeSession" | "send" | "sessionLeaseTtlMs" | "status">;
|
|
25
31
|
/** Represents a running MCP server and the operation that stops it. */
|
|
26
32
|
export type McpServerHandle = {
|
|
27
33
|
/** Locates the MCP endpoint the server is bound to. */
|
|
@@ -4,6 +4,7 @@ import { LOOPBACK_HOST } from "../configuration-defaults.js";
|
|
|
4
4
|
import { closeServer, listenServer, urlHost, writeJson } from "../shared/http.js";
|
|
5
5
|
import { isPlainObject } from "../shared/guards.js";
|
|
6
6
|
import { assertProtocolVersion, FETCH_LIMIT_DEFAULT, FETCH_LIMIT_MAX, normalizeMessageSubject, normalizeMessageType, ATTACH_HEADER, ProtocolError, validateImplementationId, validateSessionKey, } from "../broker/protocol.js";
|
|
7
|
+
import { agentIdentityStatement } from "./identity-statement.js";
|
|
7
8
|
import { MCP_TOOLS } from "./tools.js";
|
|
8
9
|
import { PAGER_VERSION } from "../version.js";
|
|
9
10
|
/**
|
|
@@ -234,7 +235,19 @@ function runTool(context, attached, tool, args, surface) {
|
|
|
234
235
|
case "update_agent_alias":
|
|
235
236
|
if (typeof args.alias !== "string")
|
|
236
237
|
throw new ProtocolError("validation_failed", "Agent alias is required");
|
|
237
|
-
return
|
|
238
|
+
return relocate(context, attached.agentId, { alias: args.alias, force: forceFlag(args.force) });
|
|
239
|
+
case "move_to_workspace":
|
|
240
|
+
if (typeof args.workspace !== "string" || args.workspace === "") {
|
|
241
|
+
throw new ProtocolError("validation_failed", "Target workspace is required");
|
|
242
|
+
}
|
|
243
|
+
if (args.alias !== undefined && typeof args.alias !== "string") {
|
|
244
|
+
throw new ProtocolError("validation_failed", "Agent alias must be a string");
|
|
245
|
+
}
|
|
246
|
+
return relocate(context, attached.agentId, {
|
|
247
|
+
workspace: args.workspace,
|
|
248
|
+
...(typeof args.alias === "string" ? { alias: args.alias } : {}),
|
|
249
|
+
force: forceFlag(args.force),
|
|
250
|
+
});
|
|
238
251
|
case "adopt_session_key": {
|
|
239
252
|
return broker.adoptSessionKey(attached.agentId, validateSessionKey(args.sessionKey));
|
|
240
253
|
}
|
|
@@ -283,6 +296,32 @@ function runTool(context, attached, tool, args, surface) {
|
|
|
283
296
|
throw new ProtocolError("validation_failed", "Unknown tool");
|
|
284
297
|
}
|
|
285
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Move or rename the caller, and tell a displaced process once the takeover is durable.
|
|
301
|
+
*
|
|
302
|
+
* The claiming process's own address is still registered under the identity it gave up; its next
|
|
303
|
+
* request registers it under the identity it now acts as.
|
|
304
|
+
* @param context - Server context carrying the broker and the optional notification port.
|
|
305
|
+
* @param agentId - Identity the caller authenticated as.
|
|
306
|
+
* @param request - Target workspace, alias, and whether to take the alias over.
|
|
307
|
+
* @returns - The identity the caller now acts as, as `whoami` reports it.
|
|
308
|
+
*/
|
|
309
|
+
function relocate(context, agentId, request) {
|
|
310
|
+
const { broker, notifications } = context;
|
|
311
|
+
const relocation = broker.relocateAgent(agentId, request);
|
|
312
|
+
if (relocation.claim) {
|
|
313
|
+
notifications?.notifySessionClaimed?.(relocation.claim);
|
|
314
|
+
notifications?.forgetDeliveryAddress?.(relocation.claim.retiredId);
|
|
315
|
+
}
|
|
316
|
+
return relocation.identity;
|
|
317
|
+
}
|
|
318
|
+
function forceFlag(value) {
|
|
319
|
+
if (value === undefined)
|
|
320
|
+
return false;
|
|
321
|
+
if (typeof value !== "boolean")
|
|
322
|
+
throw new ProtocolError("validation_failed", "force must be a boolean");
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
286
325
|
/**
|
|
287
326
|
* Report whether a decoded body claims to be a JSON-RPC request.
|
|
288
327
|
*
|
|
@@ -302,6 +341,17 @@ function isMcpRequest(value) {
|
|
|
302
341
|
function namedParameter(params, name) {
|
|
303
342
|
return isPlainObject(params) ? params[name] : undefined;
|
|
304
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Answer one JSON-RPC request on the broker's MCP endpoint.
|
|
346
|
+
*
|
|
347
|
+
* An `initialize` request attaches a new session, or resumes the one it presents, and its result's
|
|
348
|
+
* `instructions` is {@link agentIdentityStatement} for the identity the session now acts as, the
|
|
349
|
+
* same text for an attach and a resume.
|
|
350
|
+
* @param context - Broker, notification and diagnostics collaborators the server runs with.
|
|
351
|
+
* @param request - HTTP request carrying the JSON-RPC body and the session headers.
|
|
352
|
+
* @param response - HTTP response the result or error is written to.
|
|
353
|
+
* @param rpc - Decoded JSON-RPC request.
|
|
354
|
+
*/
|
|
305
355
|
function handleMcpRequest(context, request, response, rpc) {
|
|
306
356
|
const { broker } = context;
|
|
307
357
|
if (rpc.method === "initialize") {
|
|
@@ -324,8 +374,10 @@ function handleMcpRequest(context, request, response, rpc) {
|
|
|
324
374
|
// The session above is already established by the time this can throw, so an internal failure here
|
|
325
375
|
// must still carry its headers — otherwise a client that just attached or transparently resumed is
|
|
326
376
|
// left presenting credentials the server has already superseded.
|
|
377
|
+
let instructions;
|
|
327
378
|
try {
|
|
328
379
|
registerDeliveryAddress(context, request, attached.agentId);
|
|
380
|
+
instructions = agentIdentityStatement(broker.agentIdentity(attached.agentId));
|
|
329
381
|
}
|
|
330
382
|
catch (error) {
|
|
331
383
|
if (error instanceof ProtocolError) {
|
|
@@ -340,6 +392,7 @@ function handleMcpRequest(context, request, response, rpc) {
|
|
|
340
392
|
protocolVersion: typeof declared === "string" ? declared : DEFAULT_MCP_PROTOCOL_VERSION,
|
|
341
393
|
capabilities: { tools: {} },
|
|
342
394
|
serverInfo: { name: "pager", version: PAGER_VERSION },
|
|
395
|
+
instructions,
|
|
343
396
|
}, headers);
|
|
344
397
|
return;
|
|
345
398
|
}
|