@with-logic/elwood 0.1.1 → 0.1.2
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 +17 -3
- package/CONTRIBUTING.md +104 -0
- package/README.md +23 -17
- package/SECURITY.md +42 -0
- package/dist/claude/model-picker.d.ts.map +1 -1
- package/dist/claude/model-picker.js +4 -3
- package/dist/claude/model-picker.js.map +1 -1
- package/dist/claude/model-switch-confirmation.js +9 -1
- package/dist/claude/model-switch-confirmation.js.map +1 -1
- package/dist/claude/screen-table.js +1 -1
- package/dist/claude/screen-table.js.map +1 -1
- package/dist/claude/session/build.d.ts.map +1 -1
- package/dist/claude/session/build.js +3 -0
- package/dist/claude/session/build.js.map +1 -1
- package/dist/claude/session/start-or-resume.js +1 -0
- package/dist/claude/session/start-or-resume.js.map +1 -1
- package/dist/cli/interactive/spawn.d.ts.map +1 -1
- package/dist/cli/interactive/spawn.js +30 -9
- package/dist/cli/interactive/spawn.js.map +1 -1
- package/dist/cli/models/index.d.ts.map +1 -1
- package/dist/cli/models/index.js +6 -3
- package/dist/cli/models/index.js.map +1 -1
- package/dist/cli/request/layers.d.ts +2 -0
- package/dist/cli/request/layers.d.ts.map +1 -1
- package/dist/cli/request/layers.js +9 -5
- package/dist/cli/request/layers.js.map +1 -1
- package/dist/cli/sessions/index.d.ts +9 -2
- package/dist/cli/sessions/index.d.ts.map +1 -1
- package/dist/cli/sessions/index.js +2 -6
- package/dist/cli/sessions/index.js.map +1 -1
- package/dist/cli/sessions/settings.d.ts +11 -0
- package/dist/cli/sessions/settings.d.ts.map +1 -0
- package/dist/cli/sessions/settings.js +26 -0
- package/dist/cli/sessions/settings.js.map +1 -0
- package/dist/codex/model-picker.d.ts.map +1 -1
- package/dist/codex/model-picker.js +3 -2
- package/dist/codex/model-picker.js.map +1 -1
- package/dist/codex/session/build.d.ts.map +1 -1
- package/dist/codex/session/build.js +5 -7
- package/dist/codex/session/build.js.map +1 -1
- package/dist/codex/session/start-or-resume.js +1 -0
- package/dist/codex/session/start-or-resume.js.map +1 -1
- package/dist/core/input/index.d.ts.map +1 -1
- package/dist/core/input/index.js +15 -3
- package/dist/core/input/index.js.map +1 -1
- package/dist/core/models/input.d.ts +3 -0
- package/dist/core/models/input.d.ts.map +1 -0
- package/dist/core/models/input.js +9 -0
- package/dist/core/models/input.js.map +1 -0
- package/dist/core/models/picker.d.ts +2 -1
- package/dist/core/models/picker.d.ts.map +1 -1
- package/dist/core/models/picker.js +6 -5
- package/dist/core/models/picker.js.map +1 -1
- package/dist/core/models/tui-screen.d.ts +1 -1
- package/dist/core/models/tui-screen.d.ts.map +1 -1
- package/dist/core/models/tui-screen.js +26 -3
- package/dist/core/models/tui-screen.js.map +1 -1
- package/dist/runtime/session/base.d.ts.map +1 -1
- package/dist/runtime/session/base.js +4 -1
- package/dist/runtime/session/base.js.map +1 -1
- package/dist/runtime/session/commands.d.ts +2 -1
- package/dist/runtime/session/commands.d.ts.map +1 -1
- package/dist/runtime/session/commands.js +10 -4
- package/dist/runtime/session/commands.js.map +1 -1
- package/dist/runtime/session/lifecycle.d.ts +3 -4
- package/dist/runtime/session/lifecycle.d.ts.map +1 -1
- package/dist/runtime/session/lifecycle.js +6 -5
- package/dist/runtime/session/lifecycle.js.map +1 -1
- package/dist/state/directories.d.ts +4 -0
- package/dist/state/directories.d.ts.map +1 -0
- package/dist/state/directories.js +41 -0
- package/dist/state/directories.js.map +1 -0
- package/dist/state/files.d.ts.map +1 -1
- package/dist/state/files.js +25 -11
- package/dist/state/files.js.map +1 -1
- package/dist/state/private-session.d.ts.map +1 -1
- package/dist/state/private-session.js +2 -0
- package/dist/state/private-session.js.map +1 -1
- package/dist/state/store.d.ts.map +1 -1
- package/dist/state/store.js +7 -1
- package/dist/state/store.js.map +1 -1
- package/docs/cli-behavior.md +1 -1
- package/docs/codex/cli.md +57 -0
- package/docs/codex/config.md +47 -0
- package/docs/codex/hooks.md +70 -0
- package/examples/README.md +56 -0
- package/examples/full.ts +113 -0
- package/examples/minimal.ts +15 -0
- package/examples/stream.ts +39 -0
- package/examples/support.ts +71 -0
- package/package.json +10 -5
- package/prd/01-overview.md +21 -0
- package/prd/02-goals.md +47 -0
- package/prd/03-terminology.md +32 -0
- package/prd/04-architecture.md +121 -0
- package/prd/05-api/5.1-starting-claude.md +108 -0
- package/prd/05-api/5.2-resuming-claude.md +86 -0
- package/prd/05-api/5.3-claude-session-api.md +658 -0
- package/prd/05-api/5.4-events.md +145 -0
- package/prd/05-api/5.5-starting-codex.md +106 -0
- package/prd/05-api/5.6-resuming-codex.md +48 -0
- package/prd/05-api/5.7-codex-session-api.md +425 -0
- package/prd/05-api/5.8-session-api.md +122 -0
- package/prd/05-api/5.9-loops.md +170 -0
- package/prd/05-api/README.md +19 -0
- package/prd/06-claude-hook-bridge.md +164 -0
- package/prd/07-claude-policy.md +15 -0
- package/prd/07a-codex-hook-bridge.md +76 -0
- package/prd/08-state.md +127 -0
- package/prd/09-lifecycle.md +222 -0
- package/prd/10-errors.md +55 -0
- package/prd/11-test-app.md +53 -0
- package/prd/12-e2e.md +23 -0
- package/prd/12a-cli.md +375 -0
- package/prd/13-latitude.md +12 -0
- package/prd/14-conformance.md +333 -0
- package/prd/15-open-notes.md +8 -0
- package/prd/README.md +45 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
## 5.7 CodexSessionApi (the raw live session)
|
|
2
|
+
|
|
3
|
+
`CodexSessionApi` exposes the same control surface as `ClaudeSessionApi`: typed event
|
|
4
|
+
subscription, `statusDecisions`, `waitForStatus`, `waitForActivity`,
|
|
5
|
+
`sendPrompt`, `sendMessage`, `sendGuidance`, `sendKeys`, `resize`, `interrupt`,
|
|
6
|
+
`compact`, `listModels`, `setModel`, `createLoop`, `listLoops`, `cancelLoop`,
|
|
7
|
+
`stop`, `kill`, and `teardown`. Prompt
|
|
8
|
+
submission and raw input semantics are the same as Claude: Elwood writes to
|
|
9
|
+
the PTY as a human would. `sendGuidance` follows the state-aware intervention
|
|
10
|
+
contract in §5.3. `interrupt` follows the §5.3 contract — Codex also cancels a
|
|
11
|
+
running turn with a bare Escape keypress.
|
|
12
|
+
`compact` follows the §5.3 contract using Codex's `/compact` command and
|
|
13
|
+
`PostCompact` hook; `listModels`/`setModel` follow the §5.3 contract against
|
|
14
|
+
Codex's `Select Model and Effort` picker.
|
|
15
|
+
|
|
16
|
+
The package also exports `ElwoodAgentSession`, a structural supertype both
|
|
17
|
+
concrete session types satisfy, covering the shared identity/status
|
|
18
|
+
properties, the common event names (`terminal:data`, `terminal:exit`,
|
|
19
|
+
`status`, `activity`, `warning`, `loop`, `hookError`), the diagnostics and wait
|
|
20
|
+
helpers (`statusDecisions`, `waitForStatus`, `waitForActivity`), and the
|
|
21
|
+
shared io, command, and lifecycle methods. Parent-app code generic over "any
|
|
22
|
+
agent session" can be written once against this type; adapter-specific hooks
|
|
23
|
+
and events remain on the concrete types.
|
|
24
|
+
|
|
25
|
+
Both `ClaudeSession` and `CodexSession` deliver typed non-fatal issues observed
|
|
26
|
+
by Elwood through the `warning` event, projected into the adapter-neutral
|
|
27
|
+
`activity` stream. Warnings are LIVE-ONLY: there is no `warnings` snapshot
|
|
28
|
+
property — a warning is emitted once when observed and is never persisted,
|
|
29
|
+
replayed to a late subscriber, or accumulated (§5.7). Consumers collect warnings
|
|
30
|
+
by listening for the `warning` event. The MVP warning contract is:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
type ElwoodWarningEvent =
|
|
34
|
+
| {
|
|
35
|
+
readonly elwoodSessionId: string;
|
|
36
|
+
readonly agent: "claude" | "codex";
|
|
37
|
+
readonly source: "lifecycle";
|
|
38
|
+
readonly code: "version_unparseable";
|
|
39
|
+
readonly severity: "warning";
|
|
40
|
+
readonly message: string;
|
|
41
|
+
readonly raw: string;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
readonly elwoodSessionId: string;
|
|
45
|
+
readonly agent: "claude" | "codex";
|
|
46
|
+
readonly source: "lifecycle";
|
|
47
|
+
readonly code: "agent_update_failed";
|
|
48
|
+
readonly severity: "warning";
|
|
49
|
+
readonly message: string;
|
|
50
|
+
// Best-effort `claude update` / `codex update` failed, but the INSTALLED CLI
|
|
51
|
+
// still meets the minimum, so the session started from it. Safe diagnostics
|
|
52
|
+
// only: the installed version in use and an allowlisted error code (a probe
|
|
53
|
+
// timeout/`errno`); never terminal transcripts, prompts, tokens, or secrets.
|
|
54
|
+
readonly installedVersion: string;
|
|
55
|
+
readonly errorCode: string;
|
|
56
|
+
readonly raw: string;
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
readonly elwoodSessionId: string;
|
|
60
|
+
readonly agent: "codex";
|
|
61
|
+
readonly source: "terminal";
|
|
62
|
+
readonly code: "mcp_server_not_logged_in";
|
|
63
|
+
readonly severity: "warning";
|
|
64
|
+
readonly message: string;
|
|
65
|
+
readonly mcpServerName: string;
|
|
66
|
+
readonly recoveryCommand: string;
|
|
67
|
+
readonly raw: string;
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
// Claude's login lapsed AFTER the session was usable (C-CLAUDE-18). Content-
|
|
71
|
+
// free: the message and raw are FIXED canonical strings and the recovery
|
|
72
|
+
// command is the literal "/login"; no field carries banner or session text.
|
|
73
|
+
// Detection is edge-based: a banner persisting across many frames does not
|
|
74
|
+
// re-emit, so a session emits this live warning at most once for its login
|
|
75
|
+
// expiring. The session is left alive for in-place recovery via `login`.
|
|
76
|
+
readonly elwoodSessionId: string;
|
|
77
|
+
readonly agent: "claude";
|
|
78
|
+
readonly source: "terminal";
|
|
79
|
+
readonly code: "login_expired";
|
|
80
|
+
readonly severity: "warning";
|
|
81
|
+
readonly message: string;
|
|
82
|
+
readonly recoveryCommand: "/login";
|
|
83
|
+
readonly raw: string;
|
|
84
|
+
}
|
|
85
|
+
| {
|
|
86
|
+
readonly elwoodSessionId: string;
|
|
87
|
+
readonly agent: "codex";
|
|
88
|
+
readonly source: "terminal";
|
|
89
|
+
readonly code: "mcp_startup_incomplete";
|
|
90
|
+
readonly severity: "warning";
|
|
91
|
+
readonly message: string;
|
|
92
|
+
readonly failedServers: readonly string[];
|
|
93
|
+
readonly recoveryCommands: readonly string[];
|
|
94
|
+
readonly raw: string;
|
|
95
|
+
}
|
|
96
|
+
| {
|
|
97
|
+
readonly elwoodSessionId: string;
|
|
98
|
+
readonly agent: "codex";
|
|
99
|
+
readonly source: "lifecycle";
|
|
100
|
+
readonly code: "codex_default_model_persisted";
|
|
101
|
+
readonly severity: "warning";
|
|
102
|
+
readonly message: string;
|
|
103
|
+
readonly raw: string;
|
|
104
|
+
}
|
|
105
|
+
| {
|
|
106
|
+
readonly elwoodSessionId: string;
|
|
107
|
+
// Both adapters observe a committed JSONL transcript with a bounded cursor,
|
|
108
|
+
// so either can lose committed data the same three ways (unparseable,
|
|
109
|
+
// oversized, unread backlog) and surface it through this same warning.
|
|
110
|
+
readonly agent: "claude" | "codex";
|
|
111
|
+
readonly source: "terminal";
|
|
112
|
+
readonly code: "transcript_records_dropped";
|
|
113
|
+
readonly severity: "warning";
|
|
114
|
+
readonly message: string;
|
|
115
|
+
// Why data was lost: an unparseable committed record, an over-length record
|
|
116
|
+
// discarded through its next newline, or an unread teardown backlog. A bounded
|
|
117
|
+
// label only — no count and no byte magnitude (a live "something couldn't be
|
|
118
|
+
// displayed" signal, not a running tally).
|
|
119
|
+
readonly cause: "unparseable" | "oversized" | "unread_backlog";
|
|
120
|
+
readonly transcriptPath: string;
|
|
121
|
+
readonly raw: string;
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
readonly elwoodSessionId: string;
|
|
125
|
+
// Shared: either adapter's bounded transcript cursor can hit a contained fs
|
|
126
|
+
// read error (a rotation/removal/permission race) and surface it here.
|
|
127
|
+
readonly agent: "claude" | "codex";
|
|
128
|
+
readonly source: "terminal";
|
|
129
|
+
readonly code: "transcript_read_error";
|
|
130
|
+
readonly severity: "warning";
|
|
131
|
+
readonly message: string;
|
|
132
|
+
// Last error code and path only — never a count, never raw transcript content.
|
|
133
|
+
readonly lastErrorCode: string;
|
|
134
|
+
readonly transcriptPath: string;
|
|
135
|
+
readonly raw: string;
|
|
136
|
+
}
|
|
137
|
+
| {
|
|
138
|
+
readonly elwoodSessionId: string;
|
|
139
|
+
readonly agent: "claude" | "codex";
|
|
140
|
+
readonly source: "terminal";
|
|
141
|
+
readonly code: "transcript_poll_stopped";
|
|
142
|
+
readonly severity: "warning";
|
|
143
|
+
readonly message: string;
|
|
144
|
+
// A short, allowlisted error reason only — never raw transcript content.
|
|
145
|
+
readonly reason: string;
|
|
146
|
+
// Which lifecycle phase failed: a live periodic poll, or the final flush at
|
|
147
|
+
// PTY exit. Lets an operator tell lost trailing activity during shutdown from
|
|
148
|
+
// a live-watcher poll failure without a distinct warning code.
|
|
149
|
+
readonly phase: "poll" | "final_flush";
|
|
150
|
+
readonly raw: string;
|
|
151
|
+
}
|
|
152
|
+
| {
|
|
153
|
+
readonly elwoodSessionId: string;
|
|
154
|
+
readonly agent: "claude" | "codex";
|
|
155
|
+
readonly source: "lifecycle";
|
|
156
|
+
readonly code: "reap_failed";
|
|
157
|
+
readonly severity: "warning";
|
|
158
|
+
readonly message: string;
|
|
159
|
+
// The leaked leader's process-group id and a normalized, allowlisted error
|
|
160
|
+
// code only (e.g. "EPERM") — enough to locate + explain the un-reaped group,
|
|
161
|
+
// never a raw system message or conversation content.
|
|
162
|
+
readonly processGroupId: number;
|
|
163
|
+
readonly errorCode: string;
|
|
164
|
+
readonly raw: string;
|
|
165
|
+
}
|
|
166
|
+
| {
|
|
167
|
+
readonly elwoodSessionId: string;
|
|
168
|
+
readonly agent: "claude";
|
|
169
|
+
readonly source: "lifecycle";
|
|
170
|
+
readonly code: "resize_restore_failed";
|
|
171
|
+
readonly severity: "warning";
|
|
172
|
+
readonly message: string;
|
|
173
|
+
// The size Elwood tried to restore after the narrow bootstrap and a
|
|
174
|
+
// normalized, allowlisted error code only — never a raw system message
|
|
175
|
+
// or conversation content.
|
|
176
|
+
readonly requestedCols: number;
|
|
177
|
+
readonly requestedRows: number;
|
|
178
|
+
readonly errorCode: string;
|
|
179
|
+
readonly raw: string;
|
|
180
|
+
}
|
|
181
|
+
| {
|
|
182
|
+
// Agent-discriminated: the LABEL is drawn only from THAT agent's own fixed
|
|
183
|
+
// startup-prompt labels (§5.4), so an impossible pairing (Claude + `update`,
|
|
184
|
+
// Codex + `browser_tools`) is unrepresentable. Never carries raw prompt or
|
|
185
|
+
// screen content; the prompt is left retryable so a later frame re-attempts.
|
|
186
|
+
readonly elwoodSessionId: string;
|
|
187
|
+
readonly agent: "claude";
|
|
188
|
+
readonly source: "terminal";
|
|
189
|
+
readonly code: "startup_prompt_write_failed";
|
|
190
|
+
readonly severity: "warning";
|
|
191
|
+
readonly message: string;
|
|
192
|
+
readonly label: StartupPromptLabelFor<"claude">;
|
|
193
|
+
readonly raw: string;
|
|
194
|
+
}
|
|
195
|
+
| {
|
|
196
|
+
readonly elwoodSessionId: string;
|
|
197
|
+
readonly agent: "codex";
|
|
198
|
+
readonly source: "terminal";
|
|
199
|
+
readonly code: "startup_prompt_write_failed";
|
|
200
|
+
readonly severity: "warning";
|
|
201
|
+
readonly message: string;
|
|
202
|
+
readonly label: StartupPromptLabelFor<"codex">;
|
|
203
|
+
readonly raw: string;
|
|
204
|
+
}
|
|
205
|
+
| {
|
|
206
|
+
// Codex-only: restoring the user's clipboard after an image paste failed. A
|
|
207
|
+
// fixed, content-free message and raw token only — never clipboard content.
|
|
208
|
+
readonly elwoodSessionId: string;
|
|
209
|
+
readonly agent: "codex";
|
|
210
|
+
readonly source: "lifecycle";
|
|
211
|
+
readonly code: "clipboard_restore_failed";
|
|
212
|
+
readonly severity: "warning";
|
|
213
|
+
readonly message: string;
|
|
214
|
+
readonly raw: string;
|
|
215
|
+
}
|
|
216
|
+
| {
|
|
217
|
+
// Recording the one-shot initial-ready transition threw (a lifecycle-event
|
|
218
|
+
// listener), so Elwood released the control queue directly (anti-starvation)
|
|
219
|
+
// while emitted lifecycle events may be stale. Content-free. Both adapters.
|
|
220
|
+
readonly elwoodSessionId: string;
|
|
221
|
+
readonly agent: "claude" | "codex";
|
|
222
|
+
readonly source: "lifecycle";
|
|
223
|
+
readonly code: "initial_ready_fallback";
|
|
224
|
+
readonly severity: "warning";
|
|
225
|
+
readonly message: string;
|
|
226
|
+
readonly raw: string;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
A startup prompt Elwood auto-answers is marked settled and emits its
|
|
232
|
+
`startup_prompt` activity ONLY after its PTY `sendInput` write actually fulfills.
|
|
233
|
+
If that write is rejected (for example, the terminal was disposed mid-startup),
|
|
234
|
+
Elwood emits NO `startup_prompt` activity for that attempt and instead surfaces a
|
|
235
|
+
bounded, content-free `startup_prompt_write_failed` warning carrying only the
|
|
236
|
+
prompt label; the prompt is left un-settled (retryable) so a later frame
|
|
237
|
+
re-attempts it. This prevents a failed write from being reported as an answered
|
|
238
|
+
prompt and from becoming permanently unanswerable (C-CLAUDE-16, C-CODEX-17).
|
|
239
|
+
|
|
240
|
+
When Elwood recognizes an allowlisted trust prompt but its affirmative option has
|
|
241
|
+
not rendered in the current frame yet, it emits a transient `attention` activity
|
|
242
|
+
(labelled with the prompt id) at most once. This is a TRANSIENT render-delay
|
|
243
|
+
state, not a wedge: under the say-yes policy Elwood keeps watching and answers the
|
|
244
|
+
prompt on a later frame once the option paints, so there is deliberately NO
|
|
245
|
+
warning emitted for it — a warning here would read as a "not auto-answered" signal
|
|
246
|
+
even after the prompt is successfully answered (C-CLAUDE-14).
|
|
247
|
+
|
|
248
|
+
The `transcript_records_dropped` warning is emitted when committed transcript
|
|
249
|
+
records cannot be parsed as JSON, when a single un-terminated record exceeds a
|
|
250
|
+
bounded size and is discarded through its next newline (so a pathological line
|
|
251
|
+
can neither exhaust memory nor cause quadratic processing), OR when a teardown
|
|
252
|
+
drain (subagent retirement or the final flush at PTY exit) reaches its bound with
|
|
253
|
+
an unread backlog still on disk: the backlog's size is accounted as a drop rather
|
|
254
|
+
than read in a single unbounded synchronous loop. A bounded `cause`
|
|
255
|
+
discriminator distinguishes these three sources — `"unparseable"` for a malformed
|
|
256
|
+
committed record, `"oversized"` for an over-length record discarded through its
|
|
257
|
+
next newline, and `"unread_backlog"` for a teardown backlog left unread when the
|
|
258
|
+
drain bound is spent — so the warning never mislabels a valid unread backlog as an
|
|
259
|
+
unparseable record. The warning carries no count and no byte magnitude: it is a
|
|
260
|
+
live, content-free signal that some committed activity could not be displayed,
|
|
261
|
+
cause-tagged, emitted when the loss occurs. Elwood keeps no running total of
|
|
262
|
+
dropped records and does not continue any such total across resume — a human at
|
|
263
|
+
the terminal has no loss odometer, and neither does Elwood. That drain is bounded
|
|
264
|
+
two ways.
|
|
265
|
+
First, a single chunk budget is shared across every subagent retirement AND the
|
|
266
|
+
final flush for a watcher's whole lifetime — a retirement never gets a fresh full
|
|
267
|
+
budget — so aggregate terminal read work is watcher-bounded, not per-retire.
|
|
268
|
+
Second, each individual drain call stops at a small wall-clock slice, so it
|
|
269
|
+
returns to the event loop promptly instead of monopolizing it grinding a
|
|
270
|
+
hundreds-of-MiB backlog; whatever is still unread when the shared budget or the
|
|
271
|
+
slice is spent is accounted as a content-free drop. Termination and retirement
|
|
272
|
+
therefore always complete in bounded-latency work — capped by both the shared
|
|
273
|
+
budget and the per-call slice — rather than an unbounded synchronous drain.
|
|
274
|
+
|
|
275
|
+
When the first hook to carry a transcript path is a turn-boundary hook, Elwood
|
|
276
|
+
recovers the current turn already committed on disk by scanning backward from the
|
|
277
|
+
file's end to the last user prompt. That scan is bounded: it reads in fixed
|
|
278
|
+
blocks, carries any UTF-8 code point split at a block boundary so recovered text
|
|
279
|
+
is byte-exact, and reaches back only a bounded distance. If the current turn is
|
|
280
|
+
larger than that bound, Elwood recovers the committed records that fall within
|
|
281
|
+
the bounded window rather than discarding the whole turn; only the portion beyond
|
|
282
|
+
the window (including the prompt boundary itself) is not recovered, and that
|
|
283
|
+
truncation is surfaced as a bounded, content-free `transcript_records_dropped`
|
|
284
|
+
drop (`cause: "unread_backlog"`, no count, no record text) so the observability
|
|
285
|
+
gap is closed rather than silent. A turn larger
|
|
286
|
+
than the recovery bound therefore never silently loses all of its committed
|
|
287
|
+
activity. The `transcript_read_error`
|
|
288
|
+
warning is emitted when a transcript filesystem read is contained (a
|
|
289
|
+
rotation/removal/permission race): the watcher never crashes the host, but also
|
|
290
|
+
does not swallow it silently. The `transcript_poll_stopped` warning is emitted
|
|
291
|
+
when a programming error escapes the periodic transcript poll OR the final flush
|
|
292
|
+
at PTY exit: the watcher stops itself and surfaces a bounded reason rather than
|
|
293
|
+
letting the failure become an unhandled rejection or abort the exit path. A
|
|
294
|
+
bounded `phase` discriminator records which of the two failed — `"poll"` for a
|
|
295
|
+
live periodic poll, `"final_flush"` for the final flush at PTY exit — so an
|
|
296
|
+
operator can tell lost trailing activity during shutdown from a live-watcher poll
|
|
297
|
+
failure without a distinct warning code. A
|
|
298
|
+
throwing activity listener during the final flush never prevents `terminal:exit`
|
|
299
|
+
emission, the live terminal-status transition, or the process-tree reap (C-LIFE-10): the
|
|
300
|
+
flush runs behind an error boundary and lifecycle completion is guaranteed. Like
|
|
301
|
+
every warning these carry no raw conversation content: only a bounded `cause` or
|
|
302
|
+
`phase` label, an error code or short reason, and the transcript path — no counts
|
|
303
|
+
and no byte magnitude. They are live-only: emitted as a `warning` + `activity`
|
|
304
|
+
event, never persisted, deduplicated across time, or replayed to a late
|
|
305
|
+
subscriber. To keep telemetry off the hot read path, drops are COALESCED per scan
|
|
306
|
+
pass: many malformed records in one bounded scan surface at most one
|
|
307
|
+
`transcript_records_dropped` warning per `(transcript path, cause)` for that pass,
|
|
308
|
+
rather than one synchronous fan-out per record — so pathological input cannot stall
|
|
309
|
+
the event loop with millions of listener calls (§5.7, C-CLAUDE-15).
|
|
310
|
+
|
|
311
|
+
The `reap_failed` warning is emitted when the best-effort survivor reap on an
|
|
312
|
+
unsolicited PTY exit fails (for example the group SIGKILL returns `EPERM`): the
|
|
313
|
+
session still reaches its terminal status, but the descendant process group may
|
|
314
|
+
have leaked, so the risk is surfaced instead of thrown out of the native exit
|
|
315
|
+
callback. This is a live lifecycle warning (`source: "lifecycle"`) carrying only
|
|
316
|
+
the leaked leader's process-group id and a normalized error code drawn from a
|
|
317
|
+
fixed allowlist (standard error names and common Node errnos; any unrecognized
|
|
318
|
+
value collapses to `UnknownError`) — never a raw system message or a
|
|
319
|
+
caller-controlled string — emitted once through the `warning`/`activity` contract
|
|
320
|
+
like every other warning, not persisted or replayed. A reap failure on an
|
|
321
|
+
*explicit* `stop()`/`kill()`/`teardown()` is NOT downgraded to this warning: it
|
|
322
|
+
rejects with a typed `termination_failed` error so the caller learns the group
|
|
323
|
+
was not confirmed reaped (C-LIFE-10, C-ERR-01).
|
|
324
|
+
|
|
325
|
+
The `resize_restore_failed` warning is emitted when the narrow-bootstrap restore
|
|
326
|
+
of the latest requested terminal size at the one-shot initial-ready transition
|
|
327
|
+
fails with a real (non-closed) native PTY resize error: the session stays at its
|
|
328
|
+
safe bootstrap width rather than reporting a resize it never applied, but the
|
|
329
|
+
queued persona or caller message is still released so a failed restore never
|
|
330
|
+
starves input. This is a lifecycle warning (`source: "lifecycle"`, Claude only)
|
|
331
|
+
carrying only the size Elwood tried to restore and a normalized error code drawn
|
|
332
|
+
from a fixed allowlist (standard error names and common native-resize errnos; any
|
|
333
|
+
unrecognized value collapses to `UnknownError`) — never a raw system message —
|
|
334
|
+
emitted once through the `warning`/`activity` contract like every other warning,
|
|
335
|
+
not persisted or replayed. A closed-fd resize at this transition is the ordinary
|
|
336
|
+
benign process-exit race and is a silent no-op, not a warning (C-API-39).
|
|
337
|
+
|
|
338
|
+
A stopped subagent's transcript (`agent_transcript_path` on a `SubagentStop`) is
|
|
339
|
+
a one-shot input for Claude: Elwood observes that path, flushes it once, and then
|
|
340
|
+
retires it from active polling, so a long-lived session with many subagents does
|
|
341
|
+
not accumulate an unbounded set of transcript files polled for the rest of its
|
|
342
|
+
lifetime. Codex subagent transcripts are NOT observed: the Codex watcher follows
|
|
343
|
+
only the session's own rollout file, so a Codex subagent's committed records
|
|
344
|
+
reach the host only insofar as the parent session's transcript carries them.
|
|
345
|
+
`agent_transcript_path` is still validated and delivered on the Codex
|
|
346
|
+
`SubagentStop` hook payload, so a host that wants those records can read the file
|
|
347
|
+
itself. Once the session's PTY
|
|
348
|
+
exits, the watcher becomes permanently terminal: no scan, emit, or observation
|
|
349
|
+
occurs after `finish()`, so no transcript activity is ever delivered past
|
|
350
|
+
`terminal:exit`.
|
|
351
|
+
|
|
352
|
+
Warnings are LIVE-ONLY. A warning is emitted once, when observed, as a `warning`
|
|
353
|
+
event and a projected `activity` event, and is never persisted, never replayed to
|
|
354
|
+
a subscriber that attaches later, and never accumulated into a session snapshot or
|
|
355
|
+
running tally. This matches what a human at the terminal experiences: a diagnostic
|
|
356
|
+
banner appears live and then scrolls away; reopening the session does not re-show
|
|
357
|
+
the banners from before. A subscriber receives whatever warnings are emitted after
|
|
358
|
+
it subscribes. Warning `raw` fields still carry only short environment diagnostics
|
|
359
|
+
(never prompts, terminal transcripts, hook payloads, or conversation content), but
|
|
360
|
+
they are live event fields, not persisted state.
|
|
361
|
+
For `mcp_startup_incomplete`, `recoveryCommands` are derived from the failed MCP
|
|
362
|
+
server names as `codex mcp login <server>`. If Codex later emits richer recovery
|
|
363
|
+
metadata, Elwood may prefer the CLI-provided command.
|
|
364
|
+
|
|
365
|
+
`CodexSession` also emits `codex:transcript` when it can observe new items from
|
|
366
|
+
Codex's own JSONL transcript. Parent apps may use this live-only event to render
|
|
367
|
+
thinking/status phases, assistant messages, web search calls, and other
|
|
368
|
+
transcript activity that is not currently represented as a Codex hook. Those
|
|
369
|
+
same observations are also projected into the adapter-neutral `activity` event
|
|
370
|
+
stream. `CodexTranscriptSummary.kind` values are `message`, `tool_call`,
|
|
371
|
+
`tool_result`, `reasoning`, `web_search`, and `other`.
|
|
372
|
+
|
|
373
|
+
The Codex transcript reader is bounded exactly as Claude's is. Each read pass
|
|
374
|
+
reads at most a fixed chunk (256 KiB) of new committed bytes rather than the whole
|
|
375
|
+
unread delta, decoding only to the last complete UTF-8 code point so a split
|
|
376
|
+
multibyte character is never corrupted, and a scan pass consumes at most a bounded
|
|
377
|
+
per-pass chunk budget so a large delta streams across poll ticks instead of
|
|
378
|
+
blocking the event loop in one synchronous read of a hundreds-of-MiB file. A
|
|
379
|
+
single un-terminated record may buffer only up to a bounded size (1 MiB); a record
|
|
380
|
+
that exceeds it is discarded through its next newline (so a pathological no-newline
|
|
381
|
+
line can neither exhaust memory nor cause quadratic re-concatenation) and its bytes
|
|
382
|
+
are accounted as an `"oversized"` drop. At teardown — the final flush at PTY exit —
|
|
383
|
+
the reader drains to end only within a bounded chunk budget and a small wall-clock
|
|
384
|
+
slice; whatever is still unread when that bound is spent is accounted as an
|
|
385
|
+
`"unread_backlog"` drop rather than read in a single unbounded synchronous loop, so
|
|
386
|
+
the flush returns to the event loop promptly. Codex data loss is surfaced through
|
|
387
|
+
the SAME content-free `transcript_records_dropped` warning as Claude's (now
|
|
388
|
+
`agent: "codex" | "claude"`), carrying only the bounded `cause` (`"unparseable"`,
|
|
389
|
+
`"oversized"`, or `"unread_backlog"`) and the transcript path — never a count and
|
|
390
|
+
never raw transcript content — as a live event, exactly like Claude's. A
|
|
391
|
+
scan that throws (for example a downstream activity listener that raises) MUST be
|
|
392
|
+
contained rather than escaping the poll timer as an uncaught exception: the Codex
|
|
393
|
+
watcher stops itself and surfaces the SAME content-free `transcript_poll_stopped`
|
|
394
|
+
warning as Claude's (now `agent: "codex" | "claude"`), carrying only an
|
|
395
|
+
allowlisted error `reason` and the `phase`. Drops and read errors are live-only
|
|
396
|
+
signals; Elwood keeps no running total and continues nothing across resume.
|
|
397
|
+
|
|
398
|
+
As with Claude, the Codex transcript is the single source of truth for committed
|
|
399
|
+
`assistant_message`, `tool_call`, and `tool_result` activity. The Codex `Stop`,
|
|
400
|
+
`PreToolUse`, and `PostToolUse` hooks MUST NOT also project those activities from
|
|
401
|
+
their hook payloads (`last_assistant_message`, `tool_name`/`tool_input`,
|
|
402
|
+
`tool_name`/`tool_output`); doing so would surface every reply and every tool
|
|
403
|
+
step twice — once from the hook and once from the transcript. Those hooks remain
|
|
404
|
+
turn-boundary and lifecycle signals, emitted as plain `hook` activity. The
|
|
405
|
+
`Stop` hook's `last_assistant_message` in particular can carry an un-submitted
|
|
406
|
+
ghost-text draft, so — exactly as for Claude — only the committed transcript
|
|
407
|
+
turn becomes an `assistant_message` (C-CODEX-16).
|
|
408
|
+
|
|
409
|
+
`ClaudeSession` likewise observes Claude's own JSONL transcript at the
|
|
410
|
+
`transcript_path` (and `agent_transcript_path`) the hook payloads carry, and is
|
|
411
|
+
the source of truth for committed Claude activity. A Claude `assistant_message`,
|
|
412
|
+
`tool_call`, or `tool_result` MUST be projected from the committed transcript
|
|
413
|
+
record — a Messages-API turn whose `content` blocks are `text`, `tool_use`, and
|
|
414
|
+
`tool_result` — and MUST NOT be projected from the `Stop` hook's
|
|
415
|
+
`last_assistant_message`. That hook field can carry an un-sent ghost-text or
|
|
416
|
+
autocomplete suggestion the user never submitted; sourcing the message from the
|
|
417
|
+
transcript ensures only a genuinely committed turn becomes a room message
|
|
418
|
+
(C-CLAUDE-15). The `Stop` hook remains a turn-boundary signal. As with Codex,
|
|
419
|
+
Elwood flushes readable transcript data and stops the watcher when the PTY exits.
|
|
420
|
+
|
|
421
|
+
When a Codex PTY exits, Elwood must flush readable live transcript data and stop
|
|
422
|
+
the transcript watcher before emitting `terminal:exit` or terminal lifecycle
|
|
423
|
+
status. Parent applications should not receive delayed transcript activity after
|
|
424
|
+
the session has already emitted `terminal:exit`, `exited`, `stopped`, or
|
|
425
|
+
`killed`.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
## 5.8 The session API — `ClaudeSession` / `CodexSession`
|
|
2
|
+
|
|
3
|
+
`ClaudeSession` and `CodexSession` are the primary public API: ONE class per
|
|
4
|
+
adapter that exposes BOTH the ergonomic `send`/`stream` convenience (for the common
|
|
5
|
+
"ask a question, get an answer" and "watch the work" shapes) AND the full low-level
|
|
6
|
+
control surface (`sendMessage`, `sendPrompt`, `sendGuidance`, `sendKeys`, `resize`,
|
|
7
|
+
`interrupt`, `compact`, `listModels`, `setModel`, `createLoop`, `listLoops`,
|
|
8
|
+
`cancelLoop`, `on`/`off`, `waitForStatus`, `waitForActivity`,
|
|
9
|
+
`stop`/`kill`/`teardown`, and Claude's `login`). Every method is
|
|
10
|
+
lazy-start-aware. The class is a thin wrapper over the same PTY-backed session the
|
|
11
|
+
`start*` factories build — it adds no new lifecycle or persistence behavior and reads
|
|
12
|
+
only public events. The `startClaude`/`startCodex` factories remain as the low-level
|
|
13
|
+
eager entry points but are DEPRECATED in favor of the class; they still return the
|
|
14
|
+
same live session object (typed `ClaudeSessionApi`/`CodexSessionApi`) the class wraps.
|
|
15
|
+
|
|
16
|
+
**Construction (lazy start).** `ClaudeSession` / `CodexSession` are constructed
|
|
17
|
+
synchronously with the same options as the factory, except `cwd` defaults to
|
|
18
|
+
`process.cwd()` so `new ClaudeSession()` is valid (the working directory and any
|
|
19
|
+
relative `stateDir` are snapshotted at construction, so a later `process.chdir()`
|
|
20
|
+
cannot move the target project). The underlying session is NOT started at
|
|
21
|
+
construction; it starts lazily on the first `send`/`stream` or OPERATIONAL control
|
|
22
|
+
call (or an explicit `start()`), so a caller never has to reason about start ordering.
|
|
23
|
+
The SHUTDOWN methods `stop`/`kill`/`teardown` are the exception — they do NOT start a
|
|
24
|
+
session, and are no-ops before start (so `close()` is safe in a `finally`). A
|
|
25
|
+
startup failure surfaces from that first call (or from `start()`), rejecting with the
|
|
26
|
+
same typed `ElwoodError` the factory would throw. `start()` is idempotent and
|
|
27
|
+
concurrent-safe (a second `start()`, or any lazy-starting call during startup, awaits
|
|
28
|
+
the same in-flight start). `on`/`off` may be called BEFORE start — subscriptions are
|
|
29
|
+
buffered and attached when the session starts, so subscribing never forces a start —
|
|
30
|
+
and `status` reads `starting` until the underlying session exists. The started
|
|
31
|
+
underlying session is reachable via a read-only `session` accessor (undefined until
|
|
32
|
+
started) for callers that need the raw object. Only `send`/`stream` turns serialize
|
|
33
|
+
with one another; control methods (`interrupt`, `sendKeys`, `createLoop`,
|
|
34
|
+
`listLoops`, `cancelLoop`, `stop`, `kill`, …) go
|
|
35
|
+
through immediately so an intervention reaches a running turn rather than queuing.
|
|
36
|
+
|
|
37
|
+
**Turn boundary.** A "turn" begins when a `send`/`stream` submits its prompt and
|
|
38
|
+
ends when its content is complete. A turn's assistant text is transcript-sourced
|
|
39
|
+
(C-CLAUDE-15) and the transcript is written ASYNCHRONOUSLY, so the text can arrive
|
|
40
|
+
shortly AFTER the `ready` status. The boundary is therefore a COMPLETENESS ORACLE,
|
|
41
|
+
not a timer: the turn-boundary `Stop` hook carries `last_assistant_message` — the
|
|
42
|
+
final assistant text of the just-completed turn — and Elwood uses it ONLY as a
|
|
43
|
+
completeness signal (never as displayed text, since it can be un-submitted ghost
|
|
44
|
+
text). Each adapter NORMALIZES its raw hook into that signal: a turn-boundary hook
|
|
45
|
+
yields an expected-text string (empty when the boundary carries no text), and any
|
|
46
|
+
other hook yields none and is IGNORED — a `Notification` or similar hook arriving
|
|
47
|
+
after `Stop` never clears an installed signal — so the adapter-neutral turn runner
|
|
48
|
+
reads the normalized signal, never raw provider hook field names. Once `ready` is
|
|
49
|
+
observed, the turn ends the instant the transcript-collected assistant text CONTAINS
|
|
50
|
+
that expected text, so it waits exactly as long as the transcript needs. When no completeness signal is available for a turn — a pure-tool
|
|
51
|
+
turn, or an empty/null `last_assistant_message` (e.g. `StopFailure`) — the turn falls
|
|
52
|
+
back to a bounded quiet-window settle after `ready` (no new content for a short
|
|
53
|
+
window). When a NON-EMPTY expected text IS present but never appears in the
|
|
54
|
+
transcript within `catchUpMs` of `ready`, the turn does NOT fall back to the quiet
|
|
55
|
+
window: it rejects with `wait_timeout` (a promised completion that never arrives is a
|
|
56
|
+
failure, not a quiet success). A terminal status ends the turn immediately (it is
|
|
57
|
+
final). A turn may legitimately run for HOURS (running a test
|
|
58
|
+
suite, polling a PR), so there is NO whole-turn timeout by default — a still-live turn
|
|
59
|
+
is never failed by a clock; a caller may pass an OPT-IN `timeoutMs` whole-turn
|
|
60
|
+
ceiling. The tight cap is `catchUpMs` (default 10000 ms), armed only ONCE `ready`
|
|
61
|
+
fires: the agent is done, so the transcript flush should be near-instant — if it
|
|
62
|
+
stalls past `catchUpMs`, the turn rejects with `wait_timeout` rather than leaving the
|
|
63
|
+
caller hanging. Turns
|
|
64
|
+
are serialized: overlapping `send`/`stream` calls queue and run one at a time in call
|
|
65
|
+
order, so a turn's activity never interleaves with another's. This serialization covers
|
|
66
|
+
the ergonomic `send`/`stream` turns only. The raw turn-producing control methods
|
|
67
|
+
(`sendMessage`, `sendPrompt`, `sendGuidance`) submit immediately and are NOT part of
|
|
68
|
+
the ergonomic turn queue, so calling one CONCURRENTLY with an in-flight `send`/`stream`
|
|
69
|
+
turn is unsupported — its produced turn can interleave with the ergonomic turn's
|
|
70
|
+
collection. Use one mode at a time: the ergonomic `send`/`stream`, or the raw
|
|
71
|
+
turn-producing methods, not both concurrently. Turn isolation rests on
|
|
72
|
+
that SERIALIZATION — the facade holds a turn's serialized slot until the AGENT reaches
|
|
73
|
+
its real boundary, NOT merely until the consumer stops reading. On a normal turn that
|
|
74
|
+
boundary is the turn's genuine completion (the transcript caught up / went quiet), which
|
|
75
|
+
lands shortly AFTER `ready` — never bare `ready`, since transcript activity arrives after
|
|
76
|
+
`ready`. On a consumer FAILURE (timeout / catch-up / backlog) the agent may still be
|
|
77
|
+
running — and a silent long-running tool is quiet, not done — so the slot is held until a
|
|
78
|
+
reliable "turn done" signal: a real `ready` (a busy agent is `running`, not ready) or a
|
|
79
|
+
terminal status, then a short transcript-drain settle. A hung agent holds the slot until
|
|
80
|
+
`close()`/`kill()` forces a terminal status. The facade subscribes to `activity` BEFORE
|
|
81
|
+
submitting so no early event of the turn is missed. Where the adapter tags activity with a
|
|
82
|
+
`turnId` (Codex), the facade additionally binds collection to the first turn's id as a
|
|
83
|
+
refinement; Claude transcript activity carries no `turnId`, so for Claude the
|
|
84
|
+
serialized-slot boundary is the sole isolation guarantee (which is why releasing the
|
|
85
|
+
slot on the real agent boundary, not on consumer cancellation, is load-bearing).
|
|
86
|
+
Because a turn has no default timeout, its buffered state is BOUNDED: the oracle
|
|
87
|
+
matches against a rolling window of recent assistant text (not the whole turn, and
|
|
88
|
+
capped even when the Stop hook's expected text is itself huge), and a turn whose
|
|
89
|
+
UNCONSUMED events exceed an internal cap — either in COUNT (many small events) or in
|
|
90
|
+
total BYTES (a few very large payloads) — fails with `wait_timeout` rather than
|
|
91
|
+
growing without limit. The byte cap is what actually bounds memory: the count cap
|
|
92
|
+
alone cannot, since a single agent-controlled event may carry an arbitrarily large
|
|
93
|
+
string.
|
|
94
|
+
|
|
95
|
+
**`stream(prompt, options?)`** returns an async iterable of SIMPLIFIED, typed
|
|
96
|
+
events for exactly one turn, yielded in arrival order and ending when the turn
|
|
97
|
+
settles:
|
|
98
|
+
|
|
99
|
+
- `{ type: "text"; text }` — an assistant message (`assistant_message`).
|
|
100
|
+
- `{ type: "thinking"; text }` — reasoning/thinking (`reasoning`).
|
|
101
|
+
- `{ type: "tool_call"; name; input? }` — a tool invocation (`tool_call`).
|
|
102
|
+
- `{ type: "tool_result"; name?; output? }` — a tool result (`tool_result`).
|
|
103
|
+
|
|
104
|
+
Other internal activity kinds (lifecycle, hook, warning, attention, status) are
|
|
105
|
+
NOT yielded — `stream` is the turn's content, not its telemetry (those remain on
|
|
106
|
+
the `activity`/`warning` events of the underlying `session`). The simplified union
|
|
107
|
+
is a stable public contract insulated from the richer internal
|
|
108
|
+
`ElwoodActivityEvent` shape. If the turn ends by terminal exit rather than
|
|
109
|
+
`ready`, the iterator ends without error; a `timeoutMs` breach throws
|
|
110
|
+
`wait_timeout` from the iterator.
|
|
111
|
+
|
|
112
|
+
**`send(prompt, options?): Promise<string>`** is defined AS `stream` drained for
|
|
113
|
+
text: it concatenates the `text` of every `type: "text"` event of the turn, in
|
|
114
|
+
order, joined by a blank line (`\n\n`) between distinct assistant messages, and
|
|
115
|
+
resolves with that string when the turn settles. It never includes thinking or
|
|
116
|
+
tool text. A turn that produces no assistant text resolves to the empty string.
|
|
117
|
+
`send` and `stream` share one turn-boundary definition, so their completion and
|
|
118
|
+
text-collection semantics are identical.
|
|
119
|
+
|
|
120
|
+
**Teardown.** `close()` stops the underlying session (falling back to `kill` on a
|
|
121
|
+
stop failure) and is a no-op if the session never started; it is the ergonomic
|
|
122
|
+
counterpart to `stop`/`kill`/`teardown` and safe to call in a `finally`.
|