@naswerks/periscope 1.0.0 → 1.0.1
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 +6 -0
- package/README.md +2 -2
- package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
- package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
- package/contracts/wire-vectors/bulk_request.full.json +21 -0
- package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
- package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
- package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
- package/contracts/wire-vectors/link_ack.cursors.json +25 -0
- package/contracts/wire-vectors/link_ack.empty.json +16 -0
- package/contracts/wire-vectors/link_bye.cause.json +16 -0
- package/contracts/wire-vectors/link_hello.empty.json +33 -0
- package/contracts/wire-vectors/link_hello.full.json +44 -0
- package/contracts/wire-vectors/link_ping.nonce.json +16 -0
- package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
- package/contracts/wire-vectors/link_pong.nonce.json +16 -0
- package/contracts/wire-vectors/link_welcome.empty.json +18 -0
- package/contracts/wire-vectors/link_welcome.full.json +25 -0
- package/contracts/wire-vectors/refused.empty.json +10 -0
- package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
- package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
- package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
- package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
- package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
- package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
- package/contracts/wire-vectors/refused.missing-member.json +10 -0
- package/contracts/wire-vectors/refused.not-json.json +10 -0
- package/contracts/wire-vectors/refused.over-limit.json +10 -0
- package/contracts/wire-vectors/refused.seq-zero.json +10 -0
- package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
- package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
- package/contracts/wire-vectors/repository_list.root.json +19 -0
- package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
- package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
- package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
- package/contracts/wire-vectors/repository_read.head.json +20 -0
- package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
- package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
- package/contracts/wire-vectors/session_cancel.bare.json +17 -0
- package/contracts/wire-vectors/session_configure.full.json +22 -0
- package/contracts/wire-vectors/session_configure.minimal.json +20 -0
- package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
- package/contracts/wire-vectors/session_list.request.json +18 -0
- package/contracts/wire-vectors/session_list_result.empty.json +21 -0
- package/contracts/wire-vectors/session_list_result.full.json +36 -0
- package/contracts/wire-vectors/session_new.full.json +72 -0
- package/contracts/wire-vectors/session_new.minimal.json +22 -0
- package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
- package/contracts/wire-vectors/session_prompt.empty.json +18 -0
- package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
- package/contracts/wire-vectors/session_prompt.text.json +18 -0
- package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
- package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
- package/contracts/wire-vectors/session_update.agent-message.json +26 -0
- package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
- package/contracts/wire-vectors/session_update.state-transition.json +41 -0
- package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
- package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
- package/contracts/wire-vectors/transcript_list.paged.json +19 -0
- package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
- package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
- package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
- package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
- package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
- package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
- package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
- package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
- package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
- package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
- package/contracts/wire-vectors/workspace_release.request.json +22 -0
- package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
- package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
- package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
- package/package.json +6 -1
- package/src/bin/command.ts +198 -0
- package/src/bin/config.ts +104 -0
- package/src/bin/login.ts +149 -0
- package/src/bin/main.ts +204 -0
- package/src/bin/pair.ts +246 -0
- package/src/bin/periscope.ts +3 -0
- package/src/bin/posture.ts +232 -0
- package/src/bin/reconfigure.ts +266 -0
- package/src/bin/serve.ts +615 -0
- package/src/bin/status.ts +61 -0
- package/src/bin/workspaces.ts +280 -0
- package/src/control/backoff.ts +40 -0
- package/src/control/codec.ts +557 -0
- package/src/control/credential.ts +33 -0
- package/src/control/frames.ts +1586 -0
- package/src/control/link-state.ts +67 -0
- package/src/control/link.ts +894 -0
- package/src/control/queue.ts +248 -0
- package/src/control/seq.ts +76 -0
- package/src/control/stream-routing.ts +216 -0
- package/src/control/stream.ts +149 -0
- package/src/core/async-queue.ts +63 -0
- package/src/core/failure.ts +61 -0
- package/src/core/index.ts +22 -0
- package/src/core/keyed-turns.ts +47 -0
- package/src/core/paths.ts +84 -0
- package/src/core/refusal.ts +430 -0
- package/src/core/result.ts +27 -0
- package/src/core/time.ts +34 -0
- package/src/core/vocab.ts +44 -0
- package/src/core/workspace-id.ts +11 -0
- package/src/gate/command.ts +617 -0
- package/src/gate/decision.ts +173 -0
- package/src/gate/escalate.ts +164 -0
- package/src/gate/gate.ts +635 -0
- package/src/gate/index.ts +53 -0
- package/src/gate/jail.ts +210 -0
- package/src/gate/local.ts +115 -0
- package/src/gate/outcome.ts +164 -0
- package/src/gate/shell.ts +295 -0
- package/src/host/agent-process.ts +694 -0
- package/src/host/bulk-post.ts +166 -0
- package/src/host/claude-transcripts.ts +404 -0
- package/src/host/config-file.ts +246 -0
- package/src/host/git-facts.ts +119 -0
- package/src/host/hooks.ts +102 -0
- package/src/host/host.ts +1901 -0
- package/src/host/index.ts +92 -0
- package/src/host/link-state-file.ts +79 -0
- package/src/host/loopback.ts +177 -0
- package/src/host/machine.ts +27 -0
- package/src/host/mcp-server.ts +66 -0
- package/src/host/package-facts.ts +25 -0
- package/src/host/paired-credential-store.ts +140 -0
- package/src/host/paths.ts +175 -0
- package/src/host/repository-read.ts +245 -0
- package/src/host/session-store.ts +133 -0
- package/src/host/sign-in.ts +352 -0
- package/src/host/telemetry.ts +105 -0
- package/src/host/token-cache.ts +214 -0
- package/src/host/transcript-fs.ts +110 -0
- package/src/host/wire-request.ts +285 -0
- package/src/host/workspace-fs.ts +90 -0
- package/src/host/workspace-trust.ts +92 -0
- package/src/identity/authorize.ts +127 -0
- package/src/identity/config.ts +227 -0
- package/src/identity/credential.ts +176 -0
- package/src/identity/device-code.ts +217 -0
- package/src/identity/index.ts +84 -0
- package/src/identity/mode.ts +161 -0
- package/src/identity/paired-credential.ts +89 -0
- package/src/identity/pkce.ts +113 -0
- package/src/identity/store.ts +98 -0
- package/src/identity/token.ts +203 -0
- package/src/index.ts +140 -0
- package/src/mcp/descriptor.ts +75 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/schema.ts +214 -0
- package/src/mcp/server.ts +180 -0
- package/src/persistence/entry.ts +142 -0
- package/src/persistence/index.ts +47 -0
- package/src/persistence/key.ts +77 -0
- package/src/persistence/mirror.ts +118 -0
- package/src/persistence/receipt.ts +182 -0
- package/src/persistence/retention.ts +118 -0
- package/src/persistence/store.ts +183 -0
- package/src/persistence/transition-log.ts +176 -0
- package/src/protocol.ts +215 -0
- package/src/sessions/index.ts +27 -0
- package/src/sessions/registry.ts +392 -0
- package/src/sessions/session.ts +440 -0
- package/src/sessions/spawn-env.ts +318 -0
- package/src/state/coverage.ts +360 -0
- package/src/state/index.ts +58 -0
- package/src/state/machine.ts +359 -0
- package/src/state/model.ts +417 -0
- package/src/state/observer.ts +499 -0
- package/src/state/reporter.ts +60 -0
- package/src/state/store.ts +157 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/usage.ts +117 -0
- package/src/workspace/git-worktree.ts +551 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/plain-dir.ts +132 -0
- package/src/workspace/provider.ts +159 -0
- package/src/workspace/worktree-porcelain.ts +118 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one translation site: the SDK's events become this package's transitions here and nowhere
|
|
3
|
+
* else.
|
|
4
|
+
*
|
|
5
|
+
* What is consumed and what is not is decided by `coverage.ts`, not by this file. Every hook
|
|
6
|
+
* event and every message shape has a row there saying wired or declined, with a reason. This file
|
|
7
|
+
* implements the wired half; reading it alone will not tell you what was skipped, which is exactly
|
|
8
|
+
* why the table exists separately.
|
|
9
|
+
*
|
|
10
|
+
* It translates; it does not interpret. Nothing here knows what a session is for. It knows a
|
|
11
|
+
* tool call started and stopped, that a turn ended cleanly or badly, and that a task stopped
|
|
12
|
+
* holding the session. What any of that means is a controller's question.
|
|
13
|
+
*
|
|
14
|
+
* It never throws and never decides a permission. Every handler returns an empty hook output —
|
|
15
|
+
* the decision path is a separate handler on the same event, and the SDK runs both.
|
|
16
|
+
*/
|
|
17
|
+
import type { HookInput, SDKMessage } from '../host/agent-process.js';
|
|
18
|
+
import { discriminatorOf } from '../host/agent-process.js';
|
|
19
|
+
import type { Result } from '../core/result.js';
|
|
20
|
+
import type { SessionTransition, TransitionCause, TransitionWhere } from './model.js';
|
|
21
|
+
import type { EntryOp, SessionStateMachine, TransitionRequest } from './machine.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How this observer keys a permission entry. `PermissionRequest` carries no tool_use_id — see below.
|
|
25
|
+
*
|
|
26
|
+
* The name key belongs to this file's own lane — `PermissionRequest` opens it,
|
|
27
|
+
* `PermissionDenied` or the `PreToolUse` close below exits it — and to nobody else. The gate keys
|
|
28
|
+
* its hold entry by tool_use_id (`gate/outcome.ts`) precisely so the close below can never touch a
|
|
29
|
+
* hold whose decision is still outstanding: a second `PreToolUse` for the same tool would
|
|
30
|
+
* otherwise close the first call's live hold and record a resolution that never happened.
|
|
31
|
+
*/
|
|
32
|
+
const permissionEntry = (toolName: string): string => `permission:${toolName}`;
|
|
33
|
+
/** One compaction at a time per session, from either of the two sources that report it. */
|
|
34
|
+
const COMPACTION_ENTRY = 'compaction';
|
|
35
|
+
/** The model-request lane, driven by SDKStatus. */
|
|
36
|
+
const REQUEST_ENTRY = 'request';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Translates one session's SDK events into transitions on its machine.
|
|
40
|
+
*
|
|
41
|
+
* Per session because it holds the one correlation the SDK does not give for free: a background
|
|
42
|
+
* task is reported by `task_id`, while the entry it belongs to was opened under the tool's
|
|
43
|
+
* `tool_use_id`, and only `task_started` carries both.
|
|
44
|
+
*/
|
|
45
|
+
export class SessionObserver {
|
|
46
|
+
readonly #machine: SessionStateMachine;
|
|
47
|
+
/** task_id -> the entryId that task's work is recorded under. */
|
|
48
|
+
readonly #taskEntries = new Map<string, string>();
|
|
49
|
+
|
|
50
|
+
constructor(machine: SessionStateMachine) {
|
|
51
|
+
this.#machine = machine;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get machine(): SessionStateMachine {
|
|
55
|
+
return this.#machine;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The first record of a session's life: someone asked for a process. */
|
|
59
|
+
created(detail: string): Result<SessionTransition> {
|
|
60
|
+
return this.#machine.record({
|
|
61
|
+
to: 'spawning',
|
|
62
|
+
cause: { kind: 'control', event: 'create_requested', detail },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A turn was queued. Distinct from `UserPromptSubmit`, which is the agent confirming it. */
|
|
67
|
+
promptSubmitted(detail: string): Result<SessionTransition> {
|
|
68
|
+
return this.#machine.record({
|
|
69
|
+
to: this.#machine.state === 'spawning' ? 'spawning' : 'working',
|
|
70
|
+
cause: { kind: 'control', event: 'prompt_submitted', detail },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interrupted(detail: string): Result<SessionTransition> {
|
|
75
|
+
return this.#machine.record({
|
|
76
|
+
to: 'interrupted',
|
|
77
|
+
cause: { kind: 'control', event: 'interrupt_requested', detail },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The process is over, however it ended.
|
|
83
|
+
*
|
|
84
|
+
* Every still-open entry is marked abandoned here, not deleted. A session that died holding a
|
|
85
|
+
* tool call is the most useful thing this model can report, and erasing the entry to tidy up
|
|
86
|
+
* would destroy precisely that. The mark carries the reason and the entry keeps its age.
|
|
87
|
+
*/
|
|
88
|
+
ended(cause: TransitionCause): Result<SessionTransition> {
|
|
89
|
+
return this.#machine.record({
|
|
90
|
+
to: 'ended',
|
|
91
|
+
cause,
|
|
92
|
+
entry: { op: 'abandon-open', reason: `the session ended (${cause.event}) with this entry still open` },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A named condition the host itself produced — a decision path that failed rather than denied. */
|
|
97
|
+
refused(cause: TransitionCause): Result<SessionTransition> {
|
|
98
|
+
return this.#machine.record({ to: this.#machine.state, cause });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The working directory moved under the session, so every later transition must carry the new one. */
|
|
102
|
+
relocated(where: TransitionWhere, cause: TransitionCause): Result<SessionTransition> {
|
|
103
|
+
return this.#machine.record({ to: this.#machine.state, cause, where });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -------------------------------------------------------------------------
|
|
107
|
+
// The SDK message stream
|
|
108
|
+
// -------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** Every message the agent emits. Returns what it recorded — often nothing, by the table. */
|
|
111
|
+
observeMessage(message: SDKMessage): Result<SessionTransition>[] {
|
|
112
|
+
return this.#requestsFor(message).map((request) => this.#machine.record(request));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#requestsFor(message: SDKMessage): TransitionRequest[] {
|
|
116
|
+
const event = discriminatorOf(message);
|
|
117
|
+
const cause = (detail: string): TransitionCause => ({
|
|
118
|
+
kind: 'sdk-message',
|
|
119
|
+
event: event as TransitionCause['event'],
|
|
120
|
+
detail,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
to: 'ready',
|
|
127
|
+
sessionId: message.session_id,
|
|
128
|
+
cause: cause(`the agent reported itself: ${message.model} on CLI ${message.claude_code_version}`),
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (message.type === 'system' && message.subtype === 'status') {
|
|
134
|
+
return statusRequests(message.status, cause);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (message.type === 'system' && message.subtype === 'session_state_changed') {
|
|
138
|
+
// `requires_action` maps to `working`, not to a state of its own: what it requires is
|
|
139
|
+
// already carried by the open permission or elicitation entry, and a second representation
|
|
140
|
+
// of one fact is how two vocabularies start.
|
|
141
|
+
const to = message.state === 'idle' ? 'idle' : 'working';
|
|
142
|
+
return [{ to, cause: cause(`the agent reported session state ${message.state}`) }];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (message.type === 'system' && message.subtype === 'compact_boundary') {
|
|
146
|
+
return [
|
|
147
|
+
{
|
|
148
|
+
to: this.#machine.state,
|
|
149
|
+
entry: { op: 'close', entryId: COMPACTION_ENTRY },
|
|
150
|
+
cause: cause(`compaction completed (${message.compact_metadata.trigger})`),
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (message.type === 'system' && message.subtype === 'task_started') {
|
|
156
|
+
// No transition of its own — this is where the join is learned. The entry itself was opened
|
|
157
|
+
// by PreToolUse under the tool_use_id, which is the id every later report has to resolve to.
|
|
158
|
+
const entryId = message.tool_use_id ?? `task:${message.task_id}`;
|
|
159
|
+
this.#taskEntries.set(message.task_id, entryId);
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (message.type === 'system' && message.subtype === 'task_updated') {
|
|
164
|
+
return this.#taskUpdateRequests(message.task_id, message.patch, cause);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (message.type === 'system' && message.subtype === 'task_notification') {
|
|
168
|
+
const entryId = message.tool_use_id ?? this.#taskEntries.get(message.task_id);
|
|
169
|
+
if (entryId === undefined) return [];
|
|
170
|
+
this.#taskEntries.delete(message.task_id);
|
|
171
|
+
return [
|
|
172
|
+
{
|
|
173
|
+
to: this.#machine.state,
|
|
174
|
+
entry: { op: 'close', entryId },
|
|
175
|
+
cause: cause(`background task ${message.task_id} ${message.status}`),
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (message.type === 'system' && message.subtype === 'worker_shutting_down') {
|
|
181
|
+
return [{ to: this.#machine.state, cause: cause(`the worker is shutting down: ${message.reason}`) }];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (message.type === 'system' && message.subtype === 'model_refusal_no_fallback') {
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
to: this.#machine.state,
|
|
188
|
+
cause: cause(`the model refused and no fallback ran (${message.original_model})`),
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (message.type === 'result') {
|
|
194
|
+
const clean = message.subtype === 'success';
|
|
195
|
+
const why = clean
|
|
196
|
+
? `the turn completed in ${message.duration_ms}ms`
|
|
197
|
+
: `the turn ended ${message.subtype}${'terminal_reason' in message && message.terminal_reason !== undefined ? ` (${message.terminal_reason})` : ''}`;
|
|
198
|
+
return [{ to: clean ? 'idle' : 'errored', cause: cause(why) }];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#taskUpdateRequests(
|
|
205
|
+
taskId: string,
|
|
206
|
+
patch: { readonly status?: string; readonly is_backgrounded?: boolean },
|
|
207
|
+
cause: (detail: string) => TransitionCause,
|
|
208
|
+
): TransitionRequest[] {
|
|
209
|
+
const entryId = this.#taskEntries.get(taskId);
|
|
210
|
+
if (entryId === undefined) return [];
|
|
211
|
+
|
|
212
|
+
// The caused moment. A task stops holding the session exactly here, because the agent said
|
|
213
|
+
// so — never on a timer, never because an entry looked old. Backgrounding is not completion:
|
|
214
|
+
// the entry stays open, keeps ageing, and still gets its exit from task_notification.
|
|
215
|
+
if (patch.is_backgrounded === true) {
|
|
216
|
+
return [
|
|
217
|
+
{
|
|
218
|
+
to: 'idle',
|
|
219
|
+
entry: { op: 'background', entryId },
|
|
220
|
+
cause: cause(`task ${taskId} was backgrounded and no longer holds the session`),
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (patch.status === 'completed' || patch.status === 'failed' || patch.status === 'killed') {
|
|
226
|
+
this.#taskEntries.delete(taskId);
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
to: this.#machine.state,
|
|
230
|
+
entry: { op: 'close', entryId },
|
|
231
|
+
cause: cause(`task ${taskId} ${patch.status}`),
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// -------------------------------------------------------------------------
|
|
240
|
+
// The hook lane
|
|
241
|
+
// -------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/** One hook firing. Returns what it recorded, so a caller can assert on it. */
|
|
244
|
+
observeHook(input: HookInput): Result<SessionTransition>[] {
|
|
245
|
+
return this.#hookRequests(input).map((request) => this.#machine.record(request));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#hookRequests(input: HookInput): TransitionRequest[] {
|
|
249
|
+
const cause = (detail: string): TransitionCause => ({
|
|
250
|
+
kind: 'hook',
|
|
251
|
+
event: input.hook_event_name,
|
|
252
|
+
detail,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
switch (input.hook_event_name) {
|
|
256
|
+
case 'PreToolUse': {
|
|
257
|
+
// Two records, in order: the tool running means any outstanding permission for it
|
|
258
|
+
// resolved, and that resolution is the permission entry's exit on the allow path.
|
|
259
|
+
const requests: TransitionRequest[] = [];
|
|
260
|
+
const held = permissionEntry(input.tool_name);
|
|
261
|
+
if (this.#isOpen(held)) {
|
|
262
|
+
requests.push({
|
|
263
|
+
to: 'working',
|
|
264
|
+
entry: { op: 'close', entryId: held },
|
|
265
|
+
cause: cause(`the permission for ${input.tool_name} resolved and the tool is running`),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
requests.push({
|
|
269
|
+
to: 'working',
|
|
270
|
+
entry: {
|
|
271
|
+
op: 'open',
|
|
272
|
+
entryId: input.tool_use_id,
|
|
273
|
+
activity: { kind: 'tool', name: input.tool_name },
|
|
274
|
+
agentId: input.agent_id ?? null,
|
|
275
|
+
},
|
|
276
|
+
cause: cause(`${input.tool_name} started`),
|
|
277
|
+
});
|
|
278
|
+
return requests;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case 'PostToolUse':
|
|
282
|
+
return [
|
|
283
|
+
{
|
|
284
|
+
to: 'working',
|
|
285
|
+
entry: { op: 'close', entryId: input.tool_use_id },
|
|
286
|
+
cause: cause(`${input.tool_name} finished`),
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
case 'PostToolUseFailure':
|
|
291
|
+
return [
|
|
292
|
+
{
|
|
293
|
+
to: 'working',
|
|
294
|
+
entry: { op: 'close', entryId: input.tool_use_id },
|
|
295
|
+
cause: cause(`${input.tool_name} failed: ${input.error}`),
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
case 'PostToolBatch':
|
|
300
|
+
// The backstop. Closes only what is still open — emitting a close for an entry that
|
|
301
|
+
// already exited would put an event in the trace that never happened.
|
|
302
|
+
return input.tool_calls
|
|
303
|
+
.filter((call) => this.#isOpen(call.tool_use_id))
|
|
304
|
+
.map((call) => ({
|
|
305
|
+
to: 'working' as const,
|
|
306
|
+
entry: { op: 'close' as const, entryId: call.tool_use_id },
|
|
307
|
+
cause: cause(`${call.tool_name} closed by the end of its batch`),
|
|
308
|
+
}));
|
|
309
|
+
|
|
310
|
+
case 'UserPromptSubmit':
|
|
311
|
+
return [{ to: 'working', cause: cause('a turn was submitted') }];
|
|
312
|
+
|
|
313
|
+
case 'Stop':
|
|
314
|
+
return [
|
|
315
|
+
{
|
|
316
|
+
to: 'idle',
|
|
317
|
+
cause: cause(
|
|
318
|
+
`the turn ended cleanly${
|
|
319
|
+
input.background_tasks !== undefined && input.background_tasks.length > 0
|
|
320
|
+
? ` with ${input.background_tasks.length} background task(s) still in flight`
|
|
321
|
+
: ''
|
|
322
|
+
}`,
|
|
323
|
+
),
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
case 'StopFailure':
|
|
328
|
+
return [{ to: 'errored', cause: cause(`the turn ended abnormally: ${input.error}`) }];
|
|
329
|
+
|
|
330
|
+
case 'SessionStart':
|
|
331
|
+
return [{ to: this.#machine.state, cause: cause(`the session started (${input.source})`) }];
|
|
332
|
+
|
|
333
|
+
case 'SessionEnd':
|
|
334
|
+
return [
|
|
335
|
+
{
|
|
336
|
+
to: 'ended',
|
|
337
|
+
entry: {
|
|
338
|
+
op: 'abandon-open',
|
|
339
|
+
reason: `the session ended (${input.reason}) with this entry still open`,
|
|
340
|
+
},
|
|
341
|
+
cause: cause(`the session ended: ${input.reason}`),
|
|
342
|
+
},
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
case 'CwdChanged':
|
|
346
|
+
return [
|
|
347
|
+
{
|
|
348
|
+
to: this.#machine.state,
|
|
349
|
+
where: { ...this.#machine.where, cwd: input.new_cwd },
|
|
350
|
+
cause: cause(`the working directory moved from ${input.old_cwd}`),
|
|
351
|
+
},
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
case 'SubagentStart':
|
|
355
|
+
return [
|
|
356
|
+
{
|
|
357
|
+
to: 'working',
|
|
358
|
+
entry: {
|
|
359
|
+
op: 'open',
|
|
360
|
+
entryId: input.agent_id,
|
|
361
|
+
activity: { kind: 'subagent', name: input.agent_type },
|
|
362
|
+
agentId: input.agent_id,
|
|
363
|
+
},
|
|
364
|
+
cause: cause(`subagent ${input.agent_type} started`),
|
|
365
|
+
},
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
case 'SubagentStop':
|
|
369
|
+
return [
|
|
370
|
+
{
|
|
371
|
+
to: 'working',
|
|
372
|
+
entry: { op: 'close', entryId: input.agent_id },
|
|
373
|
+
cause: cause(`subagent ${input.agent_type} stopped`),
|
|
374
|
+
},
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
case 'PreCompact':
|
|
378
|
+
return [
|
|
379
|
+
{
|
|
380
|
+
to: 'working',
|
|
381
|
+
entry: { op: 'open', entryId: COMPACTION_ENTRY, activity: { kind: 'compacting', name: null } },
|
|
382
|
+
cause: cause(`compaction started (${input.trigger})`),
|
|
383
|
+
},
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
case 'PostCompact':
|
|
387
|
+
return [
|
|
388
|
+
{
|
|
389
|
+
to: 'working',
|
|
390
|
+
entry: { op: 'close', entryId: COMPACTION_ENTRY },
|
|
391
|
+
cause: cause(`compaction finished (${input.trigger})`),
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
case 'PermissionRequest':
|
|
396
|
+
// Keyed by tool name, because `PermissionRequestHookInput` carries no tool_use_id while
|
|
397
|
+
// `PermissionDeniedHookInput` does — there is no shared id to join on. Two simultaneous
|
|
398
|
+
// requests for the same tool would therefore share one entry; the exit still fires and the
|
|
399
|
+
// trace stays truthful, but the pair is not distinguishable. Stated rather than hidden.
|
|
400
|
+
return [
|
|
401
|
+
{
|
|
402
|
+
to: 'working',
|
|
403
|
+
entry: {
|
|
404
|
+
op: 'open',
|
|
405
|
+
entryId: permissionEntry(input.tool_name),
|
|
406
|
+
activity: { kind: 'permission', name: input.tool_name },
|
|
407
|
+
agentId: input.agent_id ?? null,
|
|
408
|
+
},
|
|
409
|
+
cause: cause(`a permission decision for ${input.tool_name} is outstanding`),
|
|
410
|
+
},
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
case 'PermissionDenied':
|
|
414
|
+
// A denial. An outage in the same path arrives as cause kind `refusal` with a refusal
|
|
415
|
+
// reason — two independent discriminators apart, because an infrastructure failure
|
|
416
|
+
// wearing a denial's clothes impersonates a human "no", and that is the most expensive
|
|
417
|
+
// confusion a controller can make.
|
|
418
|
+
return [
|
|
419
|
+
{
|
|
420
|
+
to: 'working',
|
|
421
|
+
entry: { op: 'close', entryId: permissionEntry(input.tool_name) },
|
|
422
|
+
cause: cause(`${input.tool_name} was denied: ${input.reason}`),
|
|
423
|
+
},
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
case 'Elicitation':
|
|
427
|
+
return [
|
|
428
|
+
{
|
|
429
|
+
to: 'working',
|
|
430
|
+
entry: {
|
|
431
|
+
op: 'open',
|
|
432
|
+
entryId: elicitationEntry(input.elicitation_id, input.mcp_server_name),
|
|
433
|
+
activity: { kind: 'elicitation', name: input.mcp_server_name },
|
|
434
|
+
},
|
|
435
|
+
cause: cause(`${input.mcp_server_name} asked for input`),
|
|
436
|
+
},
|
|
437
|
+
];
|
|
438
|
+
|
|
439
|
+
case 'ElicitationResult':
|
|
440
|
+
return [
|
|
441
|
+
{
|
|
442
|
+
to: 'working',
|
|
443
|
+
entry: { op: 'close', entryId: elicitationEntry(input.elicitation_id, input.mcp_server_name) },
|
|
444
|
+
cause: cause(`${input.mcp_server_name} elicitation ${input.action}`),
|
|
445
|
+
},
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
default:
|
|
449
|
+
// Declined by coverage.ts, every one of them with a stated reason. This is not a silent
|
|
450
|
+
// default: the table is the map, and an event absent from it is the gap.
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#isOpen(entryId: string): boolean {
|
|
456
|
+
return this.#machine.openEntries().some((entry) => entry.entryId === entryId);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const elicitationEntry = (elicitationId: string | undefined, server: string): string =>
|
|
461
|
+
`elicitation:${elicitationId ?? server}`;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* `SDKStatus` -> the request/compaction lanes. Null is the exit, not an absence of information.
|
|
465
|
+
*/
|
|
466
|
+
function statusRequests(
|
|
467
|
+
status: 'compacting' | 'requesting' | null,
|
|
468
|
+
cause: (detail: string) => TransitionCause,
|
|
469
|
+
): TransitionRequest[] {
|
|
470
|
+
if (status === 'requesting') {
|
|
471
|
+
return [
|
|
472
|
+
{
|
|
473
|
+
to: 'working',
|
|
474
|
+
entry: { op: 'open', entryId: REQUEST_ENTRY, activity: { kind: 'requesting', name: null } },
|
|
475
|
+
cause: cause('a model request is in flight'),
|
|
476
|
+
},
|
|
477
|
+
];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (status === 'compacting') {
|
|
481
|
+
return [
|
|
482
|
+
{
|
|
483
|
+
to: 'working',
|
|
484
|
+
entry: { op: 'open', entryId: COMPACTION_ENTRY, activity: { kind: 'compacting', name: null } },
|
|
485
|
+
cause: cause('compaction is in flight'),
|
|
486
|
+
},
|
|
487
|
+
];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return [
|
|
491
|
+
{
|
|
492
|
+
to: 'working',
|
|
493
|
+
entry: { op: 'close', entryId: REQUEST_ENTRY },
|
|
494
|
+
cause: cause('the model request finished'),
|
|
495
|
+
},
|
|
496
|
+
];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export type { EntryOp };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this host can say about the sessions it is holding.
|
|
3
|
+
*
|
|
4
|
+
* This is not a roster, and the distinction is not pedantry. A roster spans every session
|
|
5
|
+
* everywhere — and sessions run on different hosts, because an agent runs where its host runs. No
|
|
6
|
+
* single host can produce one; it can only produce its own row. A controller aggregates these into
|
|
7
|
+
* the thing people actually want.
|
|
8
|
+
*
|
|
9
|
+
* So this offers enumeration and nothing else: no filtering, no search, no notion of which sessions
|
|
10
|
+
* are interesting, no "who is stuck". Every one of those is a judgement, and a host that makes
|
|
11
|
+
* judgements is a host that has to be rebuilt for the next product. If this file grows a predicate,
|
|
12
|
+
* the line has been crossed.
|
|
13
|
+
*
|
|
14
|
+
* What it does owe: the open-entry age. A session that has been in one tool call for forty
|
|
15
|
+
* minutes is the single most useful thing this layer can report, and the age is what says so.
|
|
16
|
+
*/
|
|
17
|
+
import type { AgedEntry, SessionSnapshot } from './model.js';
|
|
18
|
+
import type { SessionStateMachine } from './machine.js';
|
|
19
|
+
|
|
20
|
+
export class SessionStateReporter {
|
|
21
|
+
readonly #machines = new Set<SessionStateMachine>();
|
|
22
|
+
|
|
23
|
+
/** Start reporting a session. Idempotent. */
|
|
24
|
+
add(machine: SessionStateMachine): void {
|
|
25
|
+
this.#machines.add(machine);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stop reporting a session.
|
|
30
|
+
*
|
|
31
|
+
* A caller that removes an ended session loses its open-entry evidence from this view. That is
|
|
32
|
+
* the caller's decision to make and it is not made here: nothing self-evicts, because a session
|
|
33
|
+
* that ended holding work is exactly the one somebody will want to look at.
|
|
34
|
+
*/
|
|
35
|
+
remove(machine: SessionStateMachine): void {
|
|
36
|
+
this.#machines.delete(machine);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get count(): number {
|
|
40
|
+
return this.#machines.size;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every session this host holds. Raw material — see this file's header. */
|
|
44
|
+
list(): SessionSnapshot[] {
|
|
45
|
+
return [...this.#machines].map((machine) => machine.snapshot());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every entry that has not exited, across this host's sessions, with its age and its session.
|
|
50
|
+
*
|
|
51
|
+
* Reported, never reconciled: an unpaired entry is surfaced with how long it has been open and
|
|
52
|
+
* whether it was marked abandoned, and nothing here closes one. Cleanup may mark; it may not
|
|
53
|
+
* erase, because erasing destroys the only evidence that the thing happened at all.
|
|
54
|
+
*/
|
|
55
|
+
openEntries(): (AgedEntry & { readonly sessionId: string | null })[] {
|
|
56
|
+
return [...this.#machines].flatMap((machine) =>
|
|
57
|
+
machine.openEntries().map((entry) => ({ ...entry, sessionId: machine.sessionId })),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|