@naswerks/periscope 1.0.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable transition log — the only place a gate decision is stored durably.
|
|
3
|
+
*
|
|
4
|
+
* Read this before adding any cleanup, prune or tidy-up. `state/store.ts` retains transitions in
|
|
5
|
+
* a bounded in-memory ring that actively drops its oldest once a session outruns the window, and
|
|
6
|
+
* its own header says durability belongs here. Every recorded transition also reaches the link —
|
|
7
|
+
* `forwardSession` in `control/stream.ts` subscribes to the machine and forwards whichever lane
|
|
8
|
+
* produced it — but the link is a stream, not a record: once a frame has been sent, nothing in
|
|
9
|
+
* this package can read it back. So every local refusal this host decides — a path-jail denial, a
|
|
10
|
+
* boundary command, a credential-path read, an unconvertible tool descriptor — is held in-process
|
|
11
|
+
* only by a ring that will eventually overwrite it. This file is what makes such a decision
|
|
12
|
+
* readable afterwards. A tidy-up here does not lose a log line; it loses the only stored evidence
|
|
13
|
+
* that the package's headline feature ever ran.
|
|
14
|
+
*
|
|
15
|
+
* Append-only, and an abandoned entry is marked rather than erased. "This session sat in
|
|
16
|
+
* `tool:Bash` for forty minutes" is the most useful sentence this system can produce, and a
|
|
17
|
+
* reconciler that quietly closed the entry would destroy exactly that. So `markAbandoned` writes a
|
|
18
|
+
* new record carrying the reason; it never rewrites or removes the one that is already stored.
|
|
19
|
+
*
|
|
20
|
+
* `cause` survives the round trip or the read refuses. A transition read back without the event
|
|
21
|
+
* that produced it would leave a reader with a state change and no way to know what caused it,
|
|
22
|
+
* which is exactly the inference the declared model exists to eliminate. So the encoder writes all
|
|
23
|
+
* three parts of a cause and the decoder refuses an entry that lost any of them, rather than
|
|
24
|
+
* substituting a plausible default.
|
|
25
|
+
*/
|
|
26
|
+
import type { OpenEntry, SessionTransition } from '../state/model.js';
|
|
27
|
+
import type { Result } from '../core/result.js';
|
|
28
|
+
import type { TranscriptEntry } from './entry.js';
|
|
29
|
+
/**
|
|
30
|
+
* The entry `type` a stored transition carries.
|
|
31
|
+
*
|
|
32
|
+
* Namespaced so it cannot collide with a transcript entry. These records may share a store with
|
|
33
|
+
* mirrored transcript lines, whose `type` values are the CLI's. A prefix nothing in that vocabulary
|
|
34
|
+
* uses keeps the two readable side by side and keeps a reader of either from mistaking one for the
|
|
35
|
+
* other.
|
|
36
|
+
*/
|
|
37
|
+
export declare const TRANSITION_ENTRY_TYPE = "periscope.transition";
|
|
38
|
+
/** The entry `type` an abandonment mark carries. A record in its own right, never an edit. */
|
|
39
|
+
export declare const ABANDONED_ENTRY_TYPE = "periscope.entry_abandoned";
|
|
40
|
+
/** Encode one transition as a storable entry. Lossless for everything a reader branches on. */
|
|
41
|
+
export declare function encodeTransition(transition: SessionTransition): TranscriptEntry;
|
|
42
|
+
/**
|
|
43
|
+
* Read a stored transition back.
|
|
44
|
+
*
|
|
45
|
+
* A lost or unrecognised cause is a refusal. Both halves are checked independently — the same way
|
|
46
|
+
* the machine validates them — because a cause that survived as a shape but not as a declared value
|
|
47
|
+
* is a record a reader would branch on wrongly.
|
|
48
|
+
*/
|
|
49
|
+
export declare function decodeTransition(entry: TranscriptEntry): Result<SessionTransition>;
|
|
50
|
+
/**
|
|
51
|
+
* A record that an open entry was abandoned.
|
|
52
|
+
*
|
|
53
|
+
* This is an append, not an edit, and that is the whole design. The entry it refers to stays
|
|
54
|
+
* exactly as it was written, with the time it opened and the cause that opened it. A reader
|
|
55
|
+
* assembling the two sees "opened at X, still open at Y, marked abandoned because Z" — which is the
|
|
56
|
+
* signal. Rewriting the original would leave "closed", which is the signal's opposite and is
|
|
57
|
+
* indistinguishable from an ordinary completion.
|
|
58
|
+
*/
|
|
59
|
+
export declare function markAbandoned(entry: OpenEntry, at: string, reason: string): TranscriptEntry;
|
|
60
|
+
/** Every stored transition in an entry list, in stored order. Non-transition entries are ignored. */
|
|
61
|
+
export declare function transitionsIn(entries: readonly TranscriptEntry[]): Result<SessionTransition[]>;
|
|
62
|
+
/** Every abandonment mark in an entry list, by the entry id it refers to. */
|
|
63
|
+
export declare function abandonmentsIn(entries: readonly TranscriptEntry[]): Map<string, TranscriptEntry>;
|
|
64
|
+
/**
|
|
65
|
+
* A stable uuid for one transition, so a re-append is deduplicated rather than doubled.
|
|
66
|
+
*
|
|
67
|
+
* Derived, not minted. The adapter contract treats `uuid` as an idempotency key, and a mirror
|
|
68
|
+
* retries. A random id would make every retry a new row, so the same transition written twice must
|
|
69
|
+
* carry the same id — and a session's `seq` is dense from 1 per machine, which makes the pair
|
|
70
|
+
* unique without inventing anything.
|
|
71
|
+
*/
|
|
72
|
+
export declare function transitionUuid(transition: SessionTransition): string;
|
|
73
|
+
//# sourceMappingURL=transition-log.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { isCauseEvent, isCauseKind } from '../state/model.js';
|
|
2
|
+
import { ok, refuse } from '../core/result.js';
|
|
3
|
+
/**
|
|
4
|
+
* The entry `type` a stored transition carries.
|
|
5
|
+
*
|
|
6
|
+
* Namespaced so it cannot collide with a transcript entry. These records may share a store with
|
|
7
|
+
* mirrored transcript lines, whose `type` values are the CLI's. A prefix nothing in that vocabulary
|
|
8
|
+
* uses keeps the two readable side by side and keeps a reader of either from mistaking one for the
|
|
9
|
+
* other.
|
|
10
|
+
*/
|
|
11
|
+
export const TRANSITION_ENTRY_TYPE = 'periscope.transition';
|
|
12
|
+
/** The entry `type` an abandonment mark carries. A record in its own right, never an edit. */
|
|
13
|
+
export const ABANDONED_ENTRY_TYPE = 'periscope.entry_abandoned';
|
|
14
|
+
/** Encode one transition as a storable entry. Lossless for everything a reader branches on. */
|
|
15
|
+
export function encodeTransition(transition) {
|
|
16
|
+
return {
|
|
17
|
+
type: TRANSITION_ENTRY_TYPE,
|
|
18
|
+
uuid: transitionUuid(transition),
|
|
19
|
+
timestamp: transition.at,
|
|
20
|
+
sessionId: transition.sessionId,
|
|
21
|
+
seq: transition.seq,
|
|
22
|
+
from: transition.from,
|
|
23
|
+
to: transition.to,
|
|
24
|
+
activity: transition.activity,
|
|
25
|
+
entryId: transition.entryId,
|
|
26
|
+
cause: { kind: transition.cause.kind, event: transition.cause.event, detail: transition.cause.detail },
|
|
27
|
+
where: transition.where,
|
|
28
|
+
correlationId: transition.correlationId,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read a stored transition back.
|
|
33
|
+
*
|
|
34
|
+
* A lost or unrecognised cause is a refusal. Both halves are checked independently — the same way
|
|
35
|
+
* the machine validates them — because a cause that survived as a shape but not as a declared value
|
|
36
|
+
* is a record a reader would branch on wrongly.
|
|
37
|
+
*/
|
|
38
|
+
export function decodeTransition(entry) {
|
|
39
|
+
if (entry.type !== TRANSITION_ENTRY_TYPE) {
|
|
40
|
+
return refuse('transcript-entry-malformed', `not a transition entry: type ${entry.type}`);
|
|
41
|
+
}
|
|
42
|
+
const cause = entry['cause'];
|
|
43
|
+
if (cause === null || typeof cause !== 'object' || Array.isArray(cause)) {
|
|
44
|
+
return refuse('transcript-entry-malformed', 'the transition carries no cause');
|
|
45
|
+
}
|
|
46
|
+
const { kind, event, detail } = cause;
|
|
47
|
+
if (typeof kind !== 'string' || !isCauseKind(kind)) {
|
|
48
|
+
return refuse('transcript-entry-malformed', `the cause kind is not a declared kind: ${String(kind)}`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof event !== 'string' || !isCauseEvent(event)) {
|
|
51
|
+
return refuse('transcript-entry-malformed', `the cause event is not a declared event: ${String(event)}`);
|
|
52
|
+
}
|
|
53
|
+
const seq = entry['seq'];
|
|
54
|
+
if (typeof seq !== 'number' || !Number.isFinite(seq)) {
|
|
55
|
+
return refuse('transcript-entry-malformed', 'the transition carries no seq');
|
|
56
|
+
}
|
|
57
|
+
const where = entry['where'];
|
|
58
|
+
if (where === null || typeof where !== 'object') {
|
|
59
|
+
return refuse('transcript-entry-malformed', 'the transition carries no where');
|
|
60
|
+
}
|
|
61
|
+
return ok({
|
|
62
|
+
sessionId: entry['sessionId'] ?? null,
|
|
63
|
+
seq,
|
|
64
|
+
at: typeof entry.timestamp === 'string' ? entry.timestamp : '',
|
|
65
|
+
from: entry['from'],
|
|
66
|
+
to: entry['to'],
|
|
67
|
+
activity: entry['activity'] ?? null,
|
|
68
|
+
entryId: entry['entryId'] ?? null,
|
|
69
|
+
cause: { kind, event, detail: typeof detail === 'string' ? detail : '' },
|
|
70
|
+
where: where,
|
|
71
|
+
correlationId: entry['correlationId'] ?? null,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A record that an open entry was abandoned.
|
|
76
|
+
*
|
|
77
|
+
* This is an append, not an edit, and that is the whole design. The entry it refers to stays
|
|
78
|
+
* exactly as it was written, with the time it opened and the cause that opened it. A reader
|
|
79
|
+
* assembling the two sees "opened at X, still open at Y, marked abandoned because Z" — which is the
|
|
80
|
+
* signal. Rewriting the original would leave "closed", which is the signal's opposite and is
|
|
81
|
+
* indistinguishable from an ordinary completion.
|
|
82
|
+
*/
|
|
83
|
+
export function markAbandoned(entry, at, reason) {
|
|
84
|
+
return {
|
|
85
|
+
type: ABANDONED_ENTRY_TYPE,
|
|
86
|
+
uuid: `abandoned:${entry.entryId}:${at}`,
|
|
87
|
+
timestamp: at,
|
|
88
|
+
entryId: entry.entryId,
|
|
89
|
+
activity: entry.activity,
|
|
90
|
+
lane: entry.lane,
|
|
91
|
+
openedAt: entry.openedAt,
|
|
92
|
+
agentId: entry.agentId,
|
|
93
|
+
reason,
|
|
94
|
+
cause: { kind: entry.cause.kind, event: entry.cause.event, detail: entry.cause.detail },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Every stored transition in an entry list, in stored order. Non-transition entries are ignored. */
|
|
98
|
+
export function transitionsIn(entries) {
|
|
99
|
+
const found = [];
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
if (entry.type !== TRANSITION_ENTRY_TYPE)
|
|
102
|
+
continue;
|
|
103
|
+
const decoded = decodeTransition(entry);
|
|
104
|
+
if (!decoded.ok)
|
|
105
|
+
return refuse(decoded.refusal.reason, decoded.refusal.detail);
|
|
106
|
+
found.push(decoded.value);
|
|
107
|
+
}
|
|
108
|
+
return ok(found);
|
|
109
|
+
}
|
|
110
|
+
/** Every abandonment mark in an entry list, by the entry id it refers to. */
|
|
111
|
+
export function abandonmentsIn(entries) {
|
|
112
|
+
const marks = new Map();
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (entry.type !== ABANDONED_ENTRY_TYPE)
|
|
115
|
+
continue;
|
|
116
|
+
const entryId = entry['entryId'];
|
|
117
|
+
if (typeof entryId === 'string')
|
|
118
|
+
marks.set(entryId, entry);
|
|
119
|
+
}
|
|
120
|
+
return marks;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A stable uuid for one transition, so a re-append is deduplicated rather than doubled.
|
|
124
|
+
*
|
|
125
|
+
* Derived, not minted. The adapter contract treats `uuid` as an idempotency key, and a mirror
|
|
126
|
+
* retries. A random id would make every retry a new row, so the same transition written twice must
|
|
127
|
+
* carry the same id — and a session's `seq` is dense from 1 per machine, which makes the pair
|
|
128
|
+
* unique without inventing anything.
|
|
129
|
+
*/
|
|
130
|
+
export function transitionUuid(transition) {
|
|
131
|
+
return `transition:${transition.sessionId ?? 'unidentified'}:${transition.correlationId ?? '-'}:${transition.seq}`;
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=transition-log.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire contract, on its own subpath — `@naswerks/periscope/protocol`.
|
|
3
|
+
*
|
|
4
|
+
* This is the only import path into the package that structurally cannot reach `src/host/`, so a
|
|
5
|
+
* controller written against it cannot transitively acquire `node:fs`, `node:child_process` or
|
|
6
|
+
* `node:os`, and does not pull the Agent SDK. That turns the package's blast-radius claim into
|
|
7
|
+
* something a consumer can verify at the package boundary instead of taking on trust from the
|
|
8
|
+
* package's own test suite. Pinned by src/pins/protocol-closure.test.ts.
|
|
9
|
+
*
|
|
10
|
+
* The condition that keeps it true: `exports` in package.json has exactly these two entries and no
|
|
11
|
+
* catch-all `"./*"`. A third subpath re-opens the boundary and is a decision, not a refactor.
|
|
12
|
+
*/
|
|
13
|
+
export type { AgentMessageUpdate, BulkDelivered, BulkFailed, BulkRequest, ControlFrame, ControlPayload, ControlPayloadKind, Frame, HostConfiguration, HostConfigure, HostConfigureEntry, HostConfigureResult, JsonObject, JsonValue, LinkAck, LinkBye, LinkHello, LinkPing, LinkPong, LinkWelcome, ProtocolRange, ReadRefusal, SessionCancel, SessionConfigure, SessionCursor, SessionDelta, SessionFrame, SessionList, SessionListEntry, SessionListResult, SessionNew, SessionNewEnv, SessionNewGate, SessionNewPlugin, SessionNewRequest, SessionPayload, SessionPayloadKind, SessionPrompt, SessionUpdate, SessionUpdateBody, StateTransitionUpdate, TranscriptFailed, TranscriptList, TranscriptListEntry, TranscriptListResult, TranscriptTail, TranscriptTailResult, WireRefusal, WireRefusalUpdate, RepositoryEntry, RepositoryList, RepositoryListResult, RepositoryRead, RepositoryReadResult, WorkspaceList, WorkspaceListEntry, WorkspaceListResult, WorkspaceRelease, WorkspaceReleaseBulk, WorkspaceReleaseBulkResult, WorkspaceReleaseEntry, WorkspaceReleaseEntryResult, WorkspaceReleaseFlags, WorkspaceReleaseResult, } from './control/frames.js';
|
|
14
|
+
export type { ActivityKind, AgedEntry, CauseEvent, CauseKind, EntryLane, HookEventName, OpenEntry, SessionActivity, SessionSnapshot, SessionState, SessionTransition, TransitionCause, TransitionWhere, } from './state/model.js';
|
|
15
|
+
export { ACTIVITY_KINDS, CAUSE_KINDS, HOOK_EVENTS, HOST_ACTIVITY_KINDS, SDK_ACTIVITY_KINDS, SESSION_STATES, formatActivity, isCauseEvent, isCauseKind, sameActivity, } from './state/model.js';
|
|
16
|
+
export { DROPPABLE_KINDS, MAX_CONFIGURATION_VALUE_LENGTH, MAX_BULK_RELEASES, MAX_CONFIGURE_ENTRIES, MAX_FRAME_BYTES, MAX_REPOSITORY_ENTRIES, MAX_REPOSITORY_READ_BYTES, PROTOCOL_VERSION, PROTOCOL_VERSION_MIN, TRANSCRIPT_PAGE_SIZE, TRANSCRIPT_WHAT_PREFIX, WORKSPACE_PAGE_SIZE, agentMessageDelta, agentMessageUpdate, bulkDelivered, frameId, hostConfigure, hostConfigureResult, isControlFrame, isDroppable, isSessionFrame, readAgentMessage, readRefusal, readStateTransition, readWireRefusal, repositoryList, repositoryListResult, repositoryRead, repositoryReadResult, sessionList, sessionListResult, sessionNew, sessionNewRequest, stateTransitionUpdate, transcriptFailed, transcriptList, transcriptListResult, transcriptTail, transcriptTailResult, unsetHostConfiguration, wireRefusalUpdate, workspaceList, workspaceListResult, workspaceRelease, workspaceReleaseBulk, workspaceReleaseBulkResult, workspaceReleaseEntryResult, workspaceReleaseResult, } from './control/frames.js';
|
|
17
|
+
export { decode, encode } from './control/codec.js';
|
|
18
|
+
export type { InboundCheck } from './control/seq.js';
|
|
19
|
+
export { SeqTracker } from './control/seq.js';
|
|
20
|
+
export type { Refusal, RefusalReason } from './core/refusal.js';
|
|
21
|
+
export { REFUSAL_REASONS, isRefusalReason } from './core/refusal.js';
|
|
22
|
+
export type { Result } from './core/result.js';
|
|
23
|
+
export type { Decider, Decision, DecisionReading, DecisionRequest } from './gate/decision.js';
|
|
24
|
+
export { describeRaw, readDecision } from './gate/decision.js';
|
|
25
|
+
export type { EscalationOptions, EscalationResponse, EscalationTransport } from './gate/escalate.js';
|
|
26
|
+
export type { Authorization, ControllerCredential } from './control/credential.js';
|
|
27
|
+
//# sourceMappingURL=protocol.d.ts.map
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { ACTIVITY_KINDS, CAUSE_KINDS, HOOK_EVENTS, HOST_ACTIVITY_KINDS, SDK_ACTIVITY_KINDS, SESSION_STATES, formatActivity, isCauseEvent, isCauseKind, sameActivity, } from './state/model.js';
|
|
2
|
+
export { DROPPABLE_KINDS, MAX_CONFIGURATION_VALUE_LENGTH, MAX_BULK_RELEASES, MAX_CONFIGURE_ENTRIES, MAX_FRAME_BYTES, MAX_REPOSITORY_ENTRIES, MAX_REPOSITORY_READ_BYTES, PROTOCOL_VERSION, PROTOCOL_VERSION_MIN, TRANSCRIPT_PAGE_SIZE, TRANSCRIPT_WHAT_PREFIX, WORKSPACE_PAGE_SIZE, agentMessageDelta, agentMessageUpdate, bulkDelivered, frameId, hostConfigure, hostConfigureResult, isControlFrame, isDroppable, isSessionFrame, readAgentMessage, readRefusal, readStateTransition, readWireRefusal, repositoryList, repositoryListResult, repositoryRead, repositoryReadResult, sessionList, sessionListResult, sessionNew, sessionNewRequest, stateTransitionUpdate, transcriptFailed, transcriptList, transcriptListResult, transcriptTail, transcriptTailResult, unsetHostConfiguration, wireRefusalUpdate, workspaceList, workspaceListResult, workspaceRelease, workspaceReleaseBulk, workspaceReleaseBulkResult, workspaceReleaseEntryResult, workspaceReleaseResult, } from './control/frames.js';
|
|
3
|
+
// `control/stream-routing.ts` is not exported here, deliberately. It is keyed off the SDK's own
|
|
4
|
+
// message union, so it reaches `host/` and would drag the privileged module into this subpath's
|
|
5
|
+
// closure — the one thing this file exists to prevent. What a wire consumer needs is already here:
|
|
6
|
+
// `DROPPABLE_KINDS` and `isDroppable` say which payload kinds may be lost. The routing table is the
|
|
7
|
+
// host's emission policy, and it ships from the main barrel.
|
|
8
|
+
export { decode, encode } from './control/codec.js';
|
|
9
|
+
export { SeqTracker } from './control/seq.js';
|
|
10
|
+
export { REFUSAL_REASONS, isRefusalReason } from './core/refusal.js';
|
|
11
|
+
export { describeRaw, readDecision } from './gate/decision.js';
|
|
12
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The agent session lifecycle and the registry of live sessions. */
|
|
2
|
+
export type { EnvSource, SpawnEnvPolicy } from './spawn-env.js';
|
|
3
|
+
export { DECLARED_EXACT_KEYS, DECLARED_PREFIXES, DECLARED_SUFFIXES, STRIPPED_HOST_SESSION_KEYS, composeSpawnEnv, isDeclaredSpawnEnvKey, redactProxyCredential, } from './spawn-env.js';
|
|
4
|
+
export type { HostedSessionFacts, SessionDegrade, SessionDegradeListener, SessionEndCause, SessionEndListener, SessionEnded, SessionLifecycle, SessionListener, Unsubscribe, } from './session.js';
|
|
5
|
+
export { SESSION_END_CAUSES, HostedSession } from './session.js';
|
|
6
|
+
export type { SessionRegistryOptions, SessionRequest } from './registry.js';
|
|
7
|
+
export { SessionRegistry } from './registry.js';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { DECLARED_EXACT_KEYS, DECLARED_PREFIXES, DECLARED_SUFFIXES, STRIPPED_HOST_SESSION_KEYS, composeSpawnEnv, isDeclaredSpawnEnvKey, redactProxyCredential, } from './spawn-env.js';
|
|
2
|
+
export { SESSION_END_CAUSES, HostedSession } from './session.js';
|
|
3
|
+
export { SessionRegistry } from './registry.js';
|
|
4
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live sessions this host is running, and the only thing that starts or ends one.
|
|
3
|
+
*
|
|
4
|
+
* Instance state on a class, never module globals, and that is the whole reason this is a class
|
|
5
|
+
* at all. A module-level map would make the package a singleton for its process: two embedders in
|
|
6
|
+
* one host — a test beside a running host, two controllers, one library used twice — would share a
|
|
7
|
+
* session table and end each other's work. Every piece of per-session state here is a private
|
|
8
|
+
* field, and a test runs two registries side by side to prove they cannot see each other.
|
|
9
|
+
*
|
|
10
|
+
* The ownership model in one line: this object owns lifetime, everything else borrows a handle.
|
|
11
|
+
* `create` mints, `get` borrows, `stop`/`stopAll` end. A session removes itself from here the
|
|
12
|
+
* moment it ends, whichever way it ended, so "in the registry" and "alive" are the same fact rather
|
|
13
|
+
* than two that can drift.
|
|
14
|
+
*/
|
|
15
|
+
import type { AgentSystemPrompt, HookRegistrations, McpServerConfig, SdkPluginConfig, SessionStore, SessionStoreFlush, SettingSource, SpawnAgentProcess, ThinkingConfig, EffortLevel, PermissionMode } from '../host/agent-process.js';
|
|
16
|
+
import type { WorkspaceTrust } from '../host/workspace-trust.js';
|
|
17
|
+
import type { Clock } from '../core/time.js';
|
|
18
|
+
import type { Result } from '../core/result.js';
|
|
19
|
+
import type { SpawnEnvPolicy } from './spawn-env.js';
|
|
20
|
+
import { HostedSession } from './session.js';
|
|
21
|
+
/** What a caller asks for when starting a session. */
|
|
22
|
+
export interface SessionRequest {
|
|
23
|
+
/** Absolute. Refused otherwise — a relative cwd means "relative to something you cannot see". */
|
|
24
|
+
readonly cwd: string;
|
|
25
|
+
/**
|
|
26
|
+
* Which on-disk settings tiers the agent may load. Defaults to none.
|
|
27
|
+
*
|
|
28
|
+
* Reading no settings files is the default because it is the only setting under which the host
|
|
29
|
+
* can state what an agent's permissions are. Loading the project tier also brings the trust
|
|
30
|
+
* problem back: its rules are silently void in a workspace nobody accepted. A caller that wants
|
|
31
|
+
* project instructions loaded must ask, and gets told what it costs.
|
|
32
|
+
*/
|
|
33
|
+
readonly settingSources?: readonly SettingSource[];
|
|
34
|
+
readonly plugins?: readonly SdkPluginConfig[];
|
|
35
|
+
/** Hook callbacks for this session. Carried to the process untouched — see host/hooks.ts. */
|
|
36
|
+
readonly hooks?: HookRegistrations;
|
|
37
|
+
/** Extra allow/deny/literal declarations on top of the general spawn environment. */
|
|
38
|
+
readonly env?: SpawnEnvPolicy;
|
|
39
|
+
/** A session id to continue. The controller decides when; the host only carries it out. */
|
|
40
|
+
readonly resume?: string;
|
|
41
|
+
/** With `resume`, continue into a new id instead of extending the old session. */
|
|
42
|
+
readonly fork?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Stream this session's turns as they compose. Defaults to on.
|
|
45
|
+
*
|
|
46
|
+
* On because the incremental lane is droppable by construction — a delta never enters the replay
|
|
47
|
+
* ring or the durable store, so what it costs under pressure is a repaint. Off, a turn can only
|
|
48
|
+
* be rendered after it is over, which is a different product.
|
|
49
|
+
*/
|
|
50
|
+
readonly includePartialMessages?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* How much reasoning this session emits. Defaults to the SDK's own default (deltas fire, prose
|
|
53
|
+
* is empty), so summarized prose is something a caller asks for.
|
|
54
|
+
*
|
|
55
|
+
* Why the default is not "on when someone is watching": that rule cannot be written in this
|
|
56
|
+
* package. Whether a run is watched is a judgement about what the session is for, and this host
|
|
57
|
+
* has no fact that distinguishes one — inventing one would be a name earned by observation, which
|
|
58
|
+
* is the failure the declared model exists to end. The caller knows; the host offers the knob.
|
|
59
|
+
* The asymmetry in the defaults carries the intent instead: the cheap half is on, and the half
|
|
60
|
+
* that costs tokens on the wire and puts reasoning text into transcripts and mirrors is opt-in.
|
|
61
|
+
*/
|
|
62
|
+
readonly thinking?: ThinkingConfig;
|
|
63
|
+
/** Forward a subagent's whole conversation rather than only its tool calls. Defaults to off. */
|
|
64
|
+
readonly forwardSubagentText?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* In-process MCP servers this session may call, by server name. Build them with `mcp/`.
|
|
67
|
+
*
|
|
68
|
+
* Every tool these add is decided by the same gate as `Bash` — `PreToolUse` is registered with
|
|
69
|
+
* no matcher, so coverage does not depend on the host recognising the tool. What the host's own
|
|
70
|
+
* gate does with them is a different question: see `gate/local.ts`, which matches on tool name and
|
|
71
|
+
* therefore has no opinion about an `mcp__…` tool unless the embedder names it in `ToolFamilies`.
|
|
72
|
+
*/
|
|
73
|
+
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
|
|
74
|
+
/**
|
|
75
|
+
* Ignore every MCP server this host did not declare. Defaults to true.
|
|
76
|
+
*
|
|
77
|
+
* On by default, and this is the one place a workspace's contents could otherwise decide what
|
|
78
|
+
* tools exist. Without it the agent also loads project `.mcp.json`, user settings, plugin MCP and
|
|
79
|
+
* on-disk agent frontmatter — so a repository the host checked out into a workspace can register
|
|
80
|
+
* its own servers, and a `WorkspaceProvider` that clones untrusted code becomes a tool-registration
|
|
81
|
+
* channel. The host provisions those directories programmatically, which is exactly the case where
|
|
82
|
+
* nobody reviewed what is in them.
|
|
83
|
+
*
|
|
84
|
+
* Turning it off is legitimate and costs something named. A consumer whose own repository
|
|
85
|
+
* declares the servers it wants needs this off, and forcing them to fork the package would be
|
|
86
|
+
* worse. But the cost is not only a wider tool surface: an in-process server cannot fail to
|
|
87
|
+
* connect, while the stdio and HTTP servers this re-admits can — and this package deliberately
|
|
88
|
+
* does not expose the SDK's `mcpServerStatus()` / `reconnectMcpServer()`, because reaching them
|
|
89
|
+
* means handing out the query object whose narrowing is a pinned security property. So with strict
|
|
90
|
+
* off, a failed server is neither detectable nor recoverable through this package. Strict on is
|
|
91
|
+
* what makes that absence harmless.
|
|
92
|
+
*/
|
|
93
|
+
readonly strictMcpConfig?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Mirror this session's transcript to a store. Defaults to none.
|
|
96
|
+
*
|
|
97
|
+
* The store receives every message the agent saw, so supplying one is an egress decision and it
|
|
98
|
+
* is deliberately the embedder's — this type is the local API, not the wire. Nothing arriving over
|
|
99
|
+
* the link can reach this field: a store is an object with methods and has no JSON form, so a
|
|
100
|
+
* controller cannot name a destination for a transcript even in principle. Pinned by
|
|
101
|
+
* pins/persistence-egress.test.ts.
|
|
102
|
+
*
|
|
103
|
+
* Local disk remains the primary record whatever is set here. The subprocess writes locally
|
|
104
|
+
* first and the mirror runs after that write succeeds, so a store can legitimately lag — and when
|
|
105
|
+
* a batch is dropped it stays behind, silently. See host/session-store.ts on reading that report.
|
|
106
|
+
*/
|
|
107
|
+
readonly sessionStore?: SessionStore;
|
|
108
|
+
/** How eagerly the mirror flushes. Defaults to the SDK's batching. Ignored with no store. */
|
|
109
|
+
readonly sessionStoreFlush?: SessionStoreFlush;
|
|
110
|
+
/** Create the process yourself — a VM, a container, a remote machine. See agent-process.ts. */
|
|
111
|
+
readonly spawn?: SpawnAgentProcess;
|
|
112
|
+
/**
|
|
113
|
+
* Which model runs this session. Defaults to the CLI's own.
|
|
114
|
+
*
|
|
115
|
+
* Absent, not narrowed — this and `systemPrompt` were simply not composable before, so they fill
|
|
116
|
+
* a gap rather than widen the permission narrowing. See `AGENT_SELECTION_OPTION_KEYS`.
|
|
117
|
+
*
|
|
118
|
+
* Whether it took effect is provable: the agent reports `model` on its own `system/init`, so a
|
|
119
|
+
* caller reads it back off `HostedSession`'s facts rather than trusting the request.
|
|
120
|
+
*/
|
|
121
|
+
readonly model?: string;
|
|
122
|
+
/**
|
|
123
|
+
* What the agent is told at the start. Defaults to the CLI's own preset.
|
|
124
|
+
*
|
|
125
|
+
* This one is not provable the same way. The init message carries no system prompt, so nothing
|
|
126
|
+
* the agent says back confirms it. Asking for it is a decision made blind, by construction.
|
|
127
|
+
*/
|
|
128
|
+
readonly systemPrompt?: AgentSystemPrompt;
|
|
129
|
+
/** The effort level. Defaults to the SDK's own. */
|
|
130
|
+
readonly effort?: EffortLevel;
|
|
131
|
+
/** The permission mode. Defaults to the SDK's own. See `CLI_PARITY_OPTION_KEYS`. */
|
|
132
|
+
readonly permissionMode?: PermissionMode;
|
|
133
|
+
}
|
|
134
|
+
export interface SessionRegistryOptions {
|
|
135
|
+
/** The environment sessions are filtered from. The composition root passes `process.env`. */
|
|
136
|
+
readonly baseEnv: Readonly<Record<string, string | undefined>>;
|
|
137
|
+
/** Absolute path to the user's home, for the trust read. */
|
|
138
|
+
readonly homeDir: string;
|
|
139
|
+
readonly clock?: Clock;
|
|
140
|
+
/** How long the agent has to report itself before the start is called failed. */
|
|
141
|
+
readonly startTimeoutMs?: number;
|
|
142
|
+
}
|
|
143
|
+
export declare class SessionRegistry {
|
|
144
|
+
#private;
|
|
145
|
+
constructor(options: SessionRegistryOptions);
|
|
146
|
+
/** Every live session. A copy — a caller iterating this cannot be surprised by one ending. */
|
|
147
|
+
list(): HostedSession[];
|
|
148
|
+
get liveCount(): number;
|
|
149
|
+
/** Sessions that have started but not yet reported themselves. Separate because they have no id. */
|
|
150
|
+
get provisioningCount(): number;
|
|
151
|
+
get(sessionId: string): Result<HostedSession>;
|
|
152
|
+
/**
|
|
153
|
+
* Start a session. Returns as soon as the process exists — synchronously, and without an id.
|
|
154
|
+
*
|
|
155
|
+
* Why there is no id yet, and why this is not an oversight. The agent reports itself only once
|
|
156
|
+
* a turn has been queued; before that it emits nothing at all, for as long as you care to wait
|
|
157
|
+
* (observed: 45 seconds of silence with no turn, 2.5 seconds with one). So a `create` that
|
|
158
|
+
* waited for the id would deadlock against the `prompt` that causes it.
|
|
159
|
+
*
|
|
160
|
+
* The shape a caller wants is therefore: create, prompt, then `whenLive()` if it needs the id, the
|
|
161
|
+
* version receipt or the tool list. Until then the session is in `provisioning` — held by this
|
|
162
|
+
* registry, counted by `provisioningCount`, stopped by `stopAll`, but not yet reachable by id
|
|
163
|
+
* because there is no id to reach it by.
|
|
164
|
+
*/
|
|
165
|
+
create(request: SessionRequest): Result<HostedSession>;
|
|
166
|
+
/**
|
|
167
|
+
* Start a session, send its first turn, and wait until it has reported itself.
|
|
168
|
+
*
|
|
169
|
+
* The ordinary shape, offered as one call because getting it wrong deadlocks: the turn must be
|
|
170
|
+
* queued before the wait, since it is what makes the agent initialize.
|
|
171
|
+
*/
|
|
172
|
+
open(request: SessionRequest & {
|
|
173
|
+
readonly prompt: string;
|
|
174
|
+
}): Promise<Result<HostedSession>>;
|
|
175
|
+
/** End one session. Refuses an id this registry does not hold rather than succeeding quietly. */
|
|
176
|
+
stop(sessionId: string, detail?: string): Result<void>;
|
|
177
|
+
/**
|
|
178
|
+
* End everything this registry holds, including sessions still provisioning.
|
|
179
|
+
*
|
|
180
|
+
* Provisioning sessions are included deliberately: they hold a real process even though they have
|
|
181
|
+
* no id yet, and a shutdown that only walked the keyed map would leave those running.
|
|
182
|
+
*/
|
|
183
|
+
stopAll(detail?: string): void;
|
|
184
|
+
}
|
|
185
|
+
export type { WorkspaceTrust };
|
|
186
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { startAgentProcess } from '../host/agent-process.js';
|
|
2
|
+
import { isUntrustedWorkspaceWarning, readWorkspaceTrust, trustConfigPath } from '../host/workspace-trust.js';
|
|
3
|
+
import { systemClock } from '../core/time.js';
|
|
4
|
+
import { ok, refuse } from '../core/result.js';
|
|
5
|
+
import { requireAbsolute } from '../core/paths.js';
|
|
6
|
+
import { composeSpawnEnv } from './spawn-env.js';
|
|
7
|
+
import { HostedSession } from './session.js';
|
|
8
|
+
/**
|
|
9
|
+
* 60 seconds. The process is a large native binary starting cold, and a loaded host has been
|
|
10
|
+
* observed holding a trivial invocation of it past ten. Too short turns a slow machine into a
|
|
11
|
+
* spurious outage; there is no upper bound that is too generous for a case that otherwise hangs.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_START_TIMEOUT_MS = 60_000;
|
|
14
|
+
export class SessionRegistry {
|
|
15
|
+
#live = new Map();
|
|
16
|
+
#provisioning = new Set();
|
|
17
|
+
#baseEnv;
|
|
18
|
+
#homeDir;
|
|
19
|
+
#clock;
|
|
20
|
+
#startTimeoutMs;
|
|
21
|
+
#startProcess;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.#baseEnv = options.baseEnv;
|
|
24
|
+
this.#homeDir = options.homeDir;
|
|
25
|
+
this.#clock = options.clock ?? systemClock;
|
|
26
|
+
this.#startTimeoutMs = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
|
|
27
|
+
this.#startProcess = options.startProcess ?? startAgentProcess;
|
|
28
|
+
}
|
|
29
|
+
/** Every live session. A copy — a caller iterating this cannot be surprised by one ending. */
|
|
30
|
+
list() {
|
|
31
|
+
return [...this.#live.values()];
|
|
32
|
+
}
|
|
33
|
+
get liveCount() {
|
|
34
|
+
return this.#live.size;
|
|
35
|
+
}
|
|
36
|
+
/** Sessions that have started but not yet reported themselves. Separate because they have no id. */
|
|
37
|
+
get provisioningCount() {
|
|
38
|
+
return this.#provisioning.size;
|
|
39
|
+
}
|
|
40
|
+
get(sessionId) {
|
|
41
|
+
const session = this.#live.get(sessionId);
|
|
42
|
+
if (session === undefined) {
|
|
43
|
+
return refuse('session-unknown', `no live session ${sessionId} in this registry`);
|
|
44
|
+
}
|
|
45
|
+
return ok(session);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Start a session. Returns as soon as the process exists — synchronously, and without an id.
|
|
49
|
+
*
|
|
50
|
+
* Why there is no id yet, and why this is not an oversight. The agent reports itself only once
|
|
51
|
+
* a turn has been queued; before that it emits nothing at all, for as long as you care to wait
|
|
52
|
+
* (observed: 45 seconds of silence with no turn, 2.5 seconds with one). So a `create` that
|
|
53
|
+
* waited for the id would deadlock against the `prompt` that causes it.
|
|
54
|
+
*
|
|
55
|
+
* The shape a caller wants is therefore: create, prompt, then `whenLive()` if it needs the id, the
|
|
56
|
+
* version receipt or the tool list. Until then the session is in `provisioning` — held by this
|
|
57
|
+
* registry, counted by `provisioningCount`, stopped by `stopAll`, but not yet reachable by id
|
|
58
|
+
* because there is no id to reach it by.
|
|
59
|
+
*/
|
|
60
|
+
create(request) {
|
|
61
|
+
const cwd = requireAbsolute(request.cwd);
|
|
62
|
+
if (!cwd.ok)
|
|
63
|
+
return refuse(cwd.refusal.reason, cwd.refusal.detail);
|
|
64
|
+
const settingSources = request.settingSources ?? [];
|
|
65
|
+
const trust = readWorkspaceTrust(trustConfigPath(this.#homeDir), cwd.value);
|
|
66
|
+
const session = new HostedSession(this.#startProcess({
|
|
67
|
+
cwd: cwd.value,
|
|
68
|
+
env: composeSpawnEnv(this.#baseEnv, request.env ?? {}),
|
|
69
|
+
settingSources,
|
|
70
|
+
plugins: request.plugins ?? null,
|
|
71
|
+
hooks: request.hooks ?? null,
|
|
72
|
+
resume: request.resume ?? null,
|
|
73
|
+
fork: request.fork ?? false,
|
|
74
|
+
// The two defaults that carry the streaming policy are these two lines. See SessionRequest
|
|
75
|
+
// for why the rule is stated as an asymmetry rather than as attendedness.
|
|
76
|
+
includePartialMessages: request.includePartialMessages ?? true,
|
|
77
|
+
thinking: request.thinking ?? null,
|
|
78
|
+
forwardSubagentText: request.forwardSubagentText ?? false,
|
|
79
|
+
mcpServers: request.mcpServers ?? null,
|
|
80
|
+
// The default is strict, and it is stated rather than left to the SDK. Omitting the key
|
|
81
|
+
// lets a provisioned workspace's own files register servers — see SessionRequest.
|
|
82
|
+
strictMcpConfig: request.strictMcpConfig ?? true,
|
|
83
|
+
// No default destination, and there must never be one: a host that mirrored somewhere by
|
|
84
|
+
// default would be sending transcripts off-box without anyone choosing to.
|
|
85
|
+
sessionStore: request.sessionStore ?? null,
|
|
86
|
+
sessionStoreFlush: request.sessionStoreFlush ?? null,
|
|
87
|
+
spawn: request.spawn ?? null,
|
|
88
|
+
// No default for either: naming a model here would make this package's own choice look like
|
|
89
|
+
// the CLI's, and the CLI's default is the one an operator can actually see and change.
|
|
90
|
+
model: request.model ?? null,
|
|
91
|
+
systemPrompt: request.systemPrompt ?? null,
|
|
92
|
+
effort: request.effort ?? null,
|
|
93
|
+
permissionMode: request.permissionMode ?? null,
|
|
94
|
+
// The untrusted-workspace condition is reported here and nowhere else in the SDK, so the
|
|
95
|
+
// callback is wired on every start rather than only when it is expected.
|
|
96
|
+
onStderr: (data) => {
|
|
97
|
+
if (isUntrustedWorkspaceWarning(data)) {
|
|
98
|
+
session.degrade('workspace_untrusted', data.trim());
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}), this.#clock, cwd.value, trust, (live) => this.#adopt(live), (finished) => this.#release(finished));
|
|
102
|
+
this.#provisioning.add(session);
|
|
103
|
+
// Stated before the first message, because a caller that asked for settings files in a
|
|
104
|
+
// workspace nobody trusted has already lost its rules by the time anything runs — and the only
|
|
105
|
+
// other notice is a line on stderr.
|
|
106
|
+
if (settingSources.length > 0 && trust !== 'trusted') {
|
|
107
|
+
session.degrade('workspace_untrusted', `settings sources ${settingSources.join(', ')} were requested but this workspace is ` +
|
|
108
|
+
`${trust}: permission rules from settings files will be ignored`);
|
|
109
|
+
}
|
|
110
|
+
return ok(session);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Start a session, send its first turn, and wait until it has reported itself.
|
|
114
|
+
*
|
|
115
|
+
* The ordinary shape, offered as one call because getting it wrong deadlocks: the turn must be
|
|
116
|
+
* queued before the wait, since it is what makes the agent initialize.
|
|
117
|
+
*/
|
|
118
|
+
async open(request) {
|
|
119
|
+
const created = this.create(request);
|
|
120
|
+
if (!created.ok)
|
|
121
|
+
return created;
|
|
122
|
+
const sent = created.value.prompt(request.prompt);
|
|
123
|
+
if (!sent.ok) {
|
|
124
|
+
created.value.stop('the first turn could not be queued');
|
|
125
|
+
return refuse(sent.refusal.reason, sent.refusal.detail);
|
|
126
|
+
}
|
|
127
|
+
const live = await created.value.whenLive(this.#startTimeoutMs);
|
|
128
|
+
if (!live.ok)
|
|
129
|
+
return refuse(live.refusal.reason, live.refusal.detail);
|
|
130
|
+
return ok(created.value);
|
|
131
|
+
}
|
|
132
|
+
/** End one session. Refuses an id this registry does not hold rather than succeeding quietly. */
|
|
133
|
+
stop(sessionId, detail = 'stop requested') {
|
|
134
|
+
const session = this.#live.get(sessionId);
|
|
135
|
+
if (session === undefined) {
|
|
136
|
+
return refuse('session-unknown', `no live session ${sessionId} in this registry`);
|
|
137
|
+
}
|
|
138
|
+
session.stop(detail);
|
|
139
|
+
return ok(undefined);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* End everything this registry holds, including sessions still provisioning.
|
|
143
|
+
*
|
|
144
|
+
* Provisioning sessions are included deliberately: they hold a real process even though they have
|
|
145
|
+
* no id yet, and a shutdown that only walked the keyed map would leave those running.
|
|
146
|
+
*/
|
|
147
|
+
stopAll(detail = 'host shutting down') {
|
|
148
|
+
for (const session of [...this.#live.values(), ...this.#provisioning]) {
|
|
149
|
+
session.stop(detail);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The agent has named itself, so the session becomes reachable by that id and stops provisioning.
|
|
154
|
+
*
|
|
155
|
+
* An id already held is not overwritten, and the reason is an observed SDK property: a resume
|
|
156
|
+
* without `fork` keeps the same session id. So resuming a session this registry is already
|
|
157
|
+
* running produces two live handles claiming one key. Overwriting would make the older one
|
|
158
|
+
* untracked-but-alive — invisible to `list`, unreachable by `get`, missed by `stopAll`, still
|
|
159
|
+
* holding a process — and then its eventual end would evict the newer session's entry, so a live
|
|
160
|
+
* session would become unreachable because a different one finished. Both losses are silent, and
|
|
161
|
+
* both falsify this file's own rule that "in the registry" and "alive" are the same fact.
|
|
162
|
+
*
|
|
163
|
+
* The newcomer is refused rather than the incumbent evicted: the incumbent is the one already
|
|
164
|
+
* being observed, and a degrade names the collision on the session that is about to be dropped —
|
|
165
|
+
* whose caller is the one that can do something about it.
|
|
166
|
+
*/
|
|
167
|
+
#adopt(session) {
|
|
168
|
+
const id = session.facts?.id;
|
|
169
|
+
if (id === undefined)
|
|
170
|
+
return;
|
|
171
|
+
this.#provisioning.delete(session);
|
|
172
|
+
const incumbent = this.#live.get(id);
|
|
173
|
+
if (incumbent !== undefined && incumbent !== session) {
|
|
174
|
+
session.degrade('session_id_collision', `the agent reported session id ${id}, which this registry already holds — a resume without ` +
|
|
175
|
+
`fork keeps the original id. This session stays unregistered; stop the one that holds the ` +
|
|
176
|
+
`id, or resume with fork so the agent mints a new one`);
|
|
177
|
+
session.stop(`session id ${id} is already held by a live session in this registry`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this.#live.set(id, session);
|
|
181
|
+
}
|
|
182
|
+
/** Only the holder of an id may release it — a colliding session must not evict the incumbent. */
|
|
183
|
+
#release(session) {
|
|
184
|
+
this.#provisioning.delete(session);
|
|
185
|
+
const id = session.facts?.id;
|
|
186
|
+
if (id !== undefined && this.#live.get(id) === session)
|
|
187
|
+
this.#live.delete(id);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=registry.js.map
|