@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,170 @@
|
|
|
1
|
+
## 5.9 Recurring session loops
|
|
2
|
+
|
|
3
|
+
Elwood, not either wrapped CLI, owns recurring prompt scheduling. Claude and
|
|
4
|
+
Codex expose the same typed operations on their raw session APIs, the common
|
|
5
|
+
`ElwoodAgentSession` surface, and the lazy session classes. No operation forwards
|
|
6
|
+
an adapter-native scheduling command.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
type ElwoodLoopRequest =
|
|
10
|
+
| {
|
|
11
|
+
readonly mode: "fixed";
|
|
12
|
+
readonly intervalMs: number;
|
|
13
|
+
readonly message: string;
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
readonly mode: "idle";
|
|
17
|
+
readonly message: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ElwoodLoopState = "waiting" | "scheduled" | "due" | "submitted";
|
|
21
|
+
|
|
22
|
+
interface ElwoodLoopSnapshot {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly message: string;
|
|
25
|
+
readonly mode: "fixed" | "idle";
|
|
26
|
+
readonly intervalMs?: number;
|
|
27
|
+
readonly jitterMs: number;
|
|
28
|
+
readonly createdAt: number;
|
|
29
|
+
readonly expiresAt: number;
|
|
30
|
+
readonly state: ElwoodLoopState;
|
|
31
|
+
readonly nextDueAt?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ElwoodLoopEventSnapshot = Omit<ElwoodLoopSnapshot, "message">;
|
|
35
|
+
|
|
36
|
+
type ElwoodLoopEvent =
|
|
37
|
+
| {
|
|
38
|
+
readonly kind: "created";
|
|
39
|
+
readonly loopId: string;
|
|
40
|
+
readonly at: number;
|
|
41
|
+
readonly snapshot: ElwoodLoopEventSnapshot;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
readonly kind: "fired";
|
|
45
|
+
readonly loopId: string;
|
|
46
|
+
readonly scheduledDueAt: number;
|
|
47
|
+
readonly submittedAt: number;
|
|
48
|
+
readonly snapshot: ElwoodLoopEventSnapshot & { readonly state: "submitted" };
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
readonly kind: "cancelled";
|
|
52
|
+
readonly loopId: string;
|
|
53
|
+
readonly at: number;
|
|
54
|
+
readonly reason: "caller" | "kill" | "teardown";
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
readonly kind: "expired";
|
|
58
|
+
readonly loopId: string;
|
|
59
|
+
readonly at: number;
|
|
60
|
+
}
|
|
61
|
+
| {
|
|
62
|
+
readonly kind: "failed";
|
|
63
|
+
readonly loopId: string;
|
|
64
|
+
readonly at: number;
|
|
65
|
+
readonly phase: "persistence" | "scheduling" | "submission";
|
|
66
|
+
readonly snapshot?: ElwoodLoopEventSnapshot;
|
|
67
|
+
readonly code: "loop_persistence_failed" | "loop_submission_failed";
|
|
68
|
+
readonly message: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
declare function parseLoopCommand(command: string): ElwoodLoopRequest | undefined;
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
All `at`, `createdAt`, `expiresAt`, `nextDueAt`, `scheduledDueAt`, and
|
|
75
|
+
`submittedAt` values are Unix epoch milliseconds. `intervalMs` and `jitterMs`
|
|
76
|
+
are integer milliseconds. `intervalMs` is present only for fixed loops, and
|
|
77
|
+
`nextDueAt` is present only while a live loop is `scheduled`. `listLoops()`
|
|
78
|
+
returns immutable snapshots ordered by `createdAt`, then `id`.
|
|
79
|
+
|
|
80
|
+
Creation is always explicit. `parseLoopCommand` is a package-root helper for
|
|
81
|
+
callers that choose slash-command UX; it does not schedule anything. It returns
|
|
82
|
+
`undefined` when the input is not an exact `/loop` command. After `/loop`, it
|
|
83
|
+
treats the first whitespace-delimited token as an interval only when the token
|
|
84
|
+
is a positive integer followed by exactly `s`, `m`, `h`, or `d`; it converts
|
|
85
|
+
that token to `intervalMs` and uses the remaining text as the message. Otherwise
|
|
86
|
+
the entire remainder is an idle-loop message. A recognized command with an
|
|
87
|
+
empty or over-limit message, a fixed interval outside the allowed range, or a
|
|
88
|
+
numeric conversion that is not a safe integer fails with `invalid_loop`.
|
|
89
|
+
`sendMessage` and `sendPrompt` never call this parser: `/loop ...` sent through
|
|
90
|
+
either method remains an ordinary literal message.
|
|
91
|
+
|
|
92
|
+
`createLoop` accepts the same readonly discriminated request. Messages must
|
|
93
|
+
contain between 1 and 65,536 UTF-8 bytes. A fixed `intervalMs` must be an integer
|
|
94
|
+
of at least 60,000 and less than 604,800,000 (seven days). Each session may have
|
|
95
|
+
at most 50 unexpired loop definitions; the fifty-first creation fails with
|
|
96
|
+
`loop_limit_reached` and leaves every existing loop unchanged. Invalid request
|
|
97
|
+
shapes or values fail with `invalid_loop`. Creation requires a live session;
|
|
98
|
+
the lazy classes start under their existing lazy-start contract before creating
|
|
99
|
+
a loop.
|
|
100
|
+
|
|
101
|
+
Every loop expires exactly seven wall-clock days after its original creation,
|
|
102
|
+
including time when no process is running. A fixed interval close to that
|
|
103
|
+
horizon can therefore expire without submitting once. Each loop receives a
|
|
104
|
+
stable, delay-only jitter offset derived from its ID at creation:
|
|
105
|
+
`0..min(10% of the cadence interval, 30,000 ms)`. For an idle loop the cadence
|
|
106
|
+
interval is the fixed five-minute idle threshold. The offset is never negative,
|
|
107
|
+
so a loop cannot fire earlier than its requested cadence. The exact offset is
|
|
108
|
+
persisted and is authoritative after restore; resume does not re-derive it.
|
|
109
|
+
|
|
110
|
+
A fixed loop first becomes due one full `intervalMs + jitterMs` after creation.
|
|
111
|
+
After it submits, its next clock begins at the actual submission time, not the
|
|
112
|
+
old phase. An idle loop becomes due after five continuous minutes without
|
|
113
|
+
non-loop session activity, plus its jitter. Any non-loop activity resets every
|
|
114
|
+
idle loop. A loop-generated turn rearms only its originating idle loop, from the
|
|
115
|
+
`ready` transition that completes that turn; it does not reset or restrict peer
|
|
116
|
+
idle loops.
|
|
117
|
+
|
|
118
|
+
Due work uses the ordinary readiness-safe message path. It observes the current
|
|
119
|
+
session permissions and every blocking-dialog gate at submission time, including
|
|
120
|
+
the effective posture of a resumed session rather than the posture that existed
|
|
121
|
+
when the loop was created. A due loop waits while the session is running,
|
|
122
|
+
blocked, or otherwise unable to accept a message, then submits at most once on a
|
|
123
|
+
ready transition. Missed occurrences never accumulate. Due loops submit
|
|
124
|
+
serially, one committed turn per ready transition, ordered by due time and then
|
|
125
|
+
stable `id`; latency may grow with the number of due loops and agent turn time,
|
|
126
|
+
with no separate wall-clock delivery deadline. A candidate that settles before
|
|
127
|
+
committing a turn releases the next due candidate immediately while readiness
|
|
128
|
+
remains available.
|
|
129
|
+
|
|
130
|
+
If the process stops or exits before a due prompt is submitted, its transient
|
|
131
|
+
due state is discarded and its definition becomes `waiting` for resume. A live
|
|
132
|
+
scheduling or submission failure emits a `failed` loop event and rearms the
|
|
133
|
+
loop from a fresh cadence unless the session has become non-live, in which case
|
|
134
|
+
the persisted definition waits for resume. Expiry observed before submission
|
|
135
|
+
wins over a pending due state. Live expiry emits `expired`; resume and loop
|
|
136
|
+
management on a stopped or exited session silently prune expired definitions
|
|
137
|
+
before returning.
|
|
138
|
+
|
|
139
|
+
`cancelLoop` removes one known definition and prevents every future submission.
|
|
140
|
+
If the loop already committed its prompt, cancellation does not interrupt that
|
|
141
|
+
active agent turn. An unknown, expired, cancelled, or otherwise finished ID
|
|
142
|
+
fails with `loop_not_found` and cannot affect another loop. `listLoops` and
|
|
143
|
+
`cancelLoop` remain available on stopped or exited raw sessions; `createLoop`
|
|
144
|
+
does not. The lazy wrappers apply the existing lazy-start behavior to all three
|
|
145
|
+
methods when no underlying session exists yet.
|
|
146
|
+
|
|
147
|
+
The `loop` event is live-only and follows the union above. `created` carries a
|
|
148
|
+
redacted snapshot and event time. `fired` carries a redacted `submitted`
|
|
149
|
+
snapshot, scheduled due time, and actual submission time. `cancelled` carries
|
|
150
|
+
only ID, time, and `caller | kill | teardown` reason. `expired` carries only ID
|
|
151
|
+
and time. `failed` carries ID, time, phase, a stable code, a bounded safe message,
|
|
152
|
+
and a redacted snapshot only while the loop remains active. Neither a loop event
|
|
153
|
+
nor an error detail may contain the stored prompt or serialized request; a loop
|
|
154
|
+
is identified only by ID.
|
|
155
|
+
|
|
156
|
+
`stop`, unexpected agent exit, and parent-process failure preserve definitions
|
|
157
|
+
for resume. A successful `kill` permanently clears every loop definition and
|
|
158
|
+
emits `cancelled` with reason `kill` for live definitions; `teardown` removes the
|
|
159
|
+
sidecar with all other Elwood-owned session state and uses reason `teardown` for
|
|
160
|
+
live definitions. An already-submitted turn is not interrupted merely because
|
|
161
|
+
its originating definition is removed. Resume silently removes definitions that
|
|
162
|
+
expired during downtime, preserves each surviving ID and jitter, discards all
|
|
163
|
+
missed runs, and starts every surviving fixed or idle clock fresh from restored
|
|
164
|
+
session readiness. No catch-up delivery or prior timer phase is restored.
|
|
165
|
+
|
|
166
|
+
Stable failures are `invalid_loop`, `loop_limit_reached`, `loop_not_found`,
|
|
167
|
+
`loop_persistence_failed`, and `loop_submission_failed`. Write-side persistence
|
|
168
|
+
failures use `loop_persistence_failed`; live scheduling and scheduled-message
|
|
169
|
+
submission failures use `loop_submission_failed` plus the event phase that
|
|
170
|
+
distinguishes them. Sidecar read/ownership/mode failures use `state_corrupt`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
## 5. Public TypeScript API
|
|
2
|
+
|
|
3
|
+
The package MUST expose its public API from the package root and SHOULD define an
|
|
4
|
+
explicit package `exports` map so consumers do not depend on internal module
|
|
5
|
+
paths. The exact public type names can evolve, but the MVP must expose the
|
|
6
|
+
concepts in this section, including the stable `ElwoodErrorName` union.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Sections
|
|
10
|
+
|
|
11
|
+
- [5.1 Starting Claude](5.1-starting-claude.md)
|
|
12
|
+
- [5.2 Resuming Claude](5.2-resuming-claude.md)
|
|
13
|
+
- [5.3 ClaudeSessionApi (the raw live session)](5.3-claude-session-api.md)
|
|
14
|
+
- [5.4 Events](5.4-events.md)
|
|
15
|
+
- [5.5 Starting Codex](5.5-starting-codex.md)
|
|
16
|
+
- [5.6 Resuming Codex](5.6-resuming-codex.md)
|
|
17
|
+
- [5.7 CodexSessionApi (the raw live session)](5.7-codex-session-api.md)
|
|
18
|
+
- [5.8 The session API — `ClaudeSession` / `CodexSession`](5.8-session-api.md)
|
|
19
|
+
- [5.9 Recurring session loops](5.9-loops.md)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
## 6. Claude Hook Bridge
|
|
2
|
+
|
|
3
|
+
### 6.1 Required hook coverage
|
|
4
|
+
|
|
5
|
+
Elwood MUST configure a hook bridge for every Claude Code hook event supported
|
|
6
|
+
by the installed Claude Code version, including at minimum:
|
|
7
|
+
|
|
8
|
+
- `SessionStart`
|
|
9
|
+
- `Setup`
|
|
10
|
+
- `InstructionsLoaded`
|
|
11
|
+
- `UserPromptSubmit`
|
|
12
|
+
- `UserPromptExpansion`
|
|
13
|
+
- `PreToolUse`
|
|
14
|
+
- `PermissionRequest`
|
|
15
|
+
- `PostToolUse`
|
|
16
|
+
- `PostToolUseFailure`
|
|
17
|
+
- `PostToolBatch`
|
|
18
|
+
- `PermissionDenied`
|
|
19
|
+
- `Notification`
|
|
20
|
+
- `SubagentStart`
|
|
21
|
+
- `SubagentStop`
|
|
22
|
+
- `TaskCreated`
|
|
23
|
+
- `TaskCompleted`
|
|
24
|
+
- `Stop`
|
|
25
|
+
- `StopFailure`
|
|
26
|
+
- `TeammateIdle`
|
|
27
|
+
- `ConfigChange`
|
|
28
|
+
- `CwdChanged`
|
|
29
|
+
- `FileChanged`
|
|
30
|
+
- `WorktreeCreate`
|
|
31
|
+
- `WorktreeRemove`
|
|
32
|
+
- `PreCompact`
|
|
33
|
+
- `PostCompact`
|
|
34
|
+
- `SessionEnd`
|
|
35
|
+
- `Elicitation`
|
|
36
|
+
- `ElicitationResult`
|
|
37
|
+
|
|
38
|
+
If a future Claude version adds events, the MVP may reject them as invalid hook
|
|
39
|
+
input and fail open until first-class types are added. Elwood should add an
|
|
40
|
+
unknown-but-safe event path before promising forward-compatible hook handling.
|
|
41
|
+
|
|
42
|
+
### 6.2 Routing
|
|
43
|
+
|
|
44
|
+
Each launched Claude process receives an Elwood session identifier in its
|
|
45
|
+
environment as `ELWOOD_SESSION_ID`. The hook bridge reads that identifier and
|
|
46
|
+
uses it to route hook invocations to the owning Elwood runtime over local IPC.
|
|
47
|
+
|
|
48
|
+
The IPC transport is an implementation detail, but it MUST be local-only and
|
|
49
|
+
session-scoped. A Unix domain socket is the preferred initial design on macOS.
|
|
50
|
+
The bridge must authenticate or validate that it is talking to the expected
|
|
51
|
+
local Elwood runtime, for example through an unguessable per-session token stored
|
|
52
|
+
in the generated settings/environment.
|
|
53
|
+
Bridge request framing must wait for a complete request before dispatching. A
|
|
54
|
+
partial JSON chunk must not be interpreted as a complete hook invocation, and a
|
|
55
|
+
malformed complete request must fail open with a typed hook error.
|
|
56
|
+
A request with a missing or mismatched session token must also fail open without
|
|
57
|
+
dispatching to parent handlers. Token mismatches are treated as unauthenticated
|
|
58
|
+
local IPC noise, not as hook handler failures.
|
|
59
|
+
|
|
60
|
+
### 6.3 Fail-open behavior
|
|
61
|
+
|
|
62
|
+
The hook bridge is fail-open by default.
|
|
63
|
+
|
|
64
|
+
If no parent handler is registered for a hook event, Elwood returns the same
|
|
65
|
+
observable result Claude would receive if no hook existed.
|
|
66
|
+
|
|
67
|
+
If the bridge cannot identify a hook event name from malformed input, Elwood
|
|
68
|
+
emits `hookError.hookEventName: "Unknown"` and fails open.
|
|
69
|
+
|
|
70
|
+
If a handler times out, throws, rejects, disconnects, or returns an invalid
|
|
71
|
+
runtime value, Elwood returns no decision to Claude and emits `hookError` to the
|
|
72
|
+
parent app.
|
|
73
|
+
|
|
74
|
+
Fail-open does not mean silent. `hookError` must include:
|
|
75
|
+
|
|
76
|
+
- `elwoodSessionId`
|
|
77
|
+
- hook event name
|
|
78
|
+
- error category
|
|
79
|
+
- timeout duration when applicable (only for a genuine timeout, never for a
|
|
80
|
+
handler that throws or rejects)
|
|
81
|
+
- a diagnostic message
|
|
82
|
+
|
|
83
|
+
It must not persist the full hook payload by default.
|
|
84
|
+
|
|
85
|
+
Elwood's `hookTimeoutMs` is the deadline that decides a handler timeout. The
|
|
86
|
+
per-hook `timeout` written into the generated Claude settings (the CLI's own
|
|
87
|
+
kill switch for the hook process) is `ceil(hookTimeoutMs / 1000) + 5` seconds:
|
|
88
|
+
the CLI's clock starts when it spawns the hook process, before the bridge has
|
|
89
|
+
connected and Elwood's deadline has started, so an equal value would let the CLI
|
|
90
|
+
kill the hook just before Elwood's fail-open "no decision" reply arrived. The
|
|
91
|
+
margin keeps Elwood the party that times out, so the parent always observes its
|
|
92
|
+
typed `hookError` rather than an opaque CLI-side hook failure.
|
|
93
|
+
|
|
94
|
+
The bridge caps the size of a single hook request at 8 MiB (8,388,608 bytes). The
|
|
95
|
+
cap is measured on the SAME thing in both places: the encoded wire envelope (the
|
|
96
|
+
`{token, elwoodSessionId, input}` JSON plus its framing newline), counted in the
|
|
97
|
+
child bridge script before it connects and in the parent IPC server as bytes
|
|
98
|
+
arrive — so an escape-heavy input whose JSON encoding expands past the ceiling is
|
|
99
|
+
rejected identically by both, never accepted by the child only to be dropped by
|
|
100
|
+
the server. The child additionally bounds raw stdin buffering at the same ceiling
|
|
101
|
+
as an OOM guard (the wrapped envelope can only be larger, so raw input past the cap
|
|
102
|
+
can never yield a valid request). The cap is enforced before any authentication or
|
|
103
|
+
parsing. A request whose encoded envelope exceeds the cap fails open immediately:
|
|
104
|
+
the child bridge script emits no decision and exits, and the parent server responds
|
|
105
|
+
with no decision and closes the connection without dispatching to any parent
|
|
106
|
+
handler. An oversized request is unauthenticated local IPC noise and does not emit
|
|
107
|
+
a `hookError`, matching the token-mismatch fail-open.
|
|
108
|
+
|
|
109
|
+
### 6.4 Typed hook responses
|
|
110
|
+
|
|
111
|
+
Elwood MUST model Claude hook inputs and outputs as discriminated TypeScript
|
|
112
|
+
unions.
|
|
113
|
+
|
|
114
|
+
- `hook_event_name` narrows the event payload.
|
|
115
|
+
- For tool events, `tool_name` narrows `tool_input` and response shape.
|
|
116
|
+
- Known Claude built-in tools must be typed as strongly as Claude's documented
|
|
117
|
+
schemas allow.
|
|
118
|
+
- Unknown future tools and MCP tools may use `Record<string, unknown>` or
|
|
119
|
+
`unknown` with a safe raw tool name.
|
|
120
|
+
- Hook bridge JSON must be runtime-validated before it reaches handlers.
|
|
121
|
+
|
|
122
|
+
Invalid states should be unrepresentable where TypeScript can enforce that. For
|
|
123
|
+
example, a `Notification` handler should not be able to return a blocking
|
|
124
|
+
decision, while a `PreToolUse` handler can return `allow`, `deny`, `ask`,
|
|
125
|
+
`defer`, `updatedInput`, and/or `additionalContext` according to Claude's rules.
|
|
126
|
+
`PreToolUse` may also return `additionalContext` without a permission decision.
|
|
127
|
+
Typed `updatedInput` rewrites MUST be authored through a tool-keyed handler such
|
|
128
|
+
as `PreToolUse.Bash` or `PreToolUse.AskUserQuestion`, so the returned input
|
|
129
|
+
rewrite is checked against that exact tool's input type. The event-name
|
|
130
|
+
`PreToolUse(event)` handler form may return permission decisions or context, but
|
|
131
|
+
must not expose `updatedInput`. Elwood must also validate returned
|
|
132
|
+
`updatedInput` against the actual tool input shape for known tools before
|
|
133
|
+
serializing it.
|
|
134
|
+
|
|
135
|
+
Claude hook response support also includes:
|
|
136
|
+
|
|
137
|
+
- `PermissionRequest` may return `allow` or `deny`, optional `updatedInput`,
|
|
138
|
+
`updatedPermissions`, `message`, or `interrupt`.
|
|
139
|
+
Typed `updatedInput` rewrites follow the same tool-keyed handler rule as
|
|
140
|
+
`PreToolUse`.
|
|
141
|
+
- `PermissionDenied` may return `{ retry: true }`.
|
|
142
|
+
- context hooks such as `SessionStart`, `Setup`, and `SubagentStart` may return
|
|
143
|
+
`additionalContext`, `initialUserMessage`, or `watchPaths`.
|
|
144
|
+
- `PostToolUse` may return `additionalContext`, `updatedToolOutput`, or
|
|
145
|
+
`updatedMCPToolOutput`, or block with a reason. Every field is optional, so
|
|
146
|
+
an empty object is a valid no-op result (as it is for the context hooks).
|
|
147
|
+
- `WorktreeCreate` may return `{ worktreePath: string }`.
|
|
148
|
+
- `TeammateIdle`, `TaskCreated`, and `TaskCompleted` may return
|
|
149
|
+
`{ continue: false, stopReason? }`.
|
|
150
|
+
|
|
151
|
+
### 6.5 Readiness
|
|
152
|
+
|
|
153
|
+
The `Stop` hook is the canonical signal that a Claude turn completed and the
|
|
154
|
+
session is ready for the next prompt. If a `Stop` handler blocks stopping and
|
|
155
|
+
returns feedback to Claude, Elwood must not mark the session ready.
|
|
156
|
+
|
|
157
|
+
The headless terminal screen model may be used for startup automation,
|
|
158
|
+
environment warnings, diagnostics, or UI hints. On a **cold start** it is not the
|
|
159
|
+
source of truth for readiness — a rendered composer there is only a placeholder
|
|
160
|
+
and the hook/deadline path decides readiness. **On resume this is relaxed** (see
|
|
161
|
+
§5.3 and C-API-28): because the input loop is already live, the first rendered,
|
|
162
|
+
quiet, non-blocking composer frame is an accepted readiness source, racing the
|
|
163
|
+
readiness hook and the deadline. A blocking dialog on that frame does not mark
|
|
164
|
+
ready.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
## 7. Claude Tool And Permission Policy
|
|
2
|
+
|
|
3
|
+
`startClaude` MUST accept options for Claude launch policy, including permission
|
|
4
|
+
mode and allowed/disallowed tools. Elwood translates these options to Claude
|
|
5
|
+
Code-supported CLI flags or generated settings.
|
|
6
|
+
|
|
7
|
+
Hook handlers remain the mechanism for dynamic policy. The two approaches are
|
|
8
|
+
complementary:
|
|
9
|
+
|
|
10
|
+
- launch policy narrows Claude's baseline capability;
|
|
11
|
+
- hooks observe and control individual lifecycle events and tool calls.
|
|
12
|
+
|
|
13
|
+
`AskUserQuestion` is explicitly in scope. Elwood must type it and allow a
|
|
14
|
+
`PreToolUse` handler to answer it programmatically by returning an allowed
|
|
15
|
+
decision with updated input containing answers.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
## 7A. Codex Hook Bridge And Policy
|
|
2
|
+
|
|
3
|
+
### 7A.1 Required hook coverage
|
|
4
|
+
|
|
5
|
+
Elwood MUST configure a hook bridge for every documented Codex hook event,
|
|
6
|
+
including at minimum:
|
|
7
|
+
|
|
8
|
+
- `SessionStart`
|
|
9
|
+
- `SubagentStart`
|
|
10
|
+
- `PreToolUse`
|
|
11
|
+
- `PermissionRequest`
|
|
12
|
+
- `PostToolUse`
|
|
13
|
+
- `PreCompact`
|
|
14
|
+
- `PostCompact`
|
|
15
|
+
- `UserPromptSubmit`
|
|
16
|
+
- `SubagentStop`
|
|
17
|
+
- `Stop`
|
|
18
|
+
|
|
19
|
+
### 7A.2 Typed hook responses
|
|
20
|
+
|
|
21
|
+
Elwood MUST model Codex hook inputs and outputs as discriminated TypeScript
|
|
22
|
+
unions.
|
|
23
|
+
|
|
24
|
+
- `hook_event_name` narrows the event payload.
|
|
25
|
+
- For tool events, `tool_name` narrows known tool inputs for `Bash` and
|
|
26
|
+
`apply_patch`; MCP tools use their raw `mcp__*` names, and other future raw
|
|
27
|
+
Codex tool names are normalized to `unknown:<raw-name>` before dispatch so
|
|
28
|
+
known-tool narrowing remains reliable while preserving a safe generic input
|
|
29
|
+
path.
|
|
30
|
+
- `PreToolUse` may deny, allow, allow a rewritten input, or add context
|
|
31
|
+
according to Codex's current supported response shapes. Typed `updatedInput`
|
|
32
|
+
rewrites MUST be authored through a tool-keyed handler such as
|
|
33
|
+
`PreToolUse.Bash` or `PreToolUse.apply_patch`; the event-name
|
|
34
|
+
`PreToolUse(event)` handler form may return permission decisions or context,
|
|
35
|
+
but must not expose `updatedInput`.
|
|
36
|
+
- `PermissionRequest` may allow or deny only. Future-only fields such as
|
|
37
|
+
updated input, updated permissions, and interrupts must be unrepresentable.
|
|
38
|
+
Serialized output must put `behavior` and optional `message` inside the
|
|
39
|
+
event-specific `decision` object with `hookEventName`.
|
|
40
|
+
- `Stop` and `SubagentStop` may block completion with a continuation reason and
|
|
41
|
+
optional additional context, or return `continue: false` with a stop reason
|
|
42
|
+
and optional additional context.
|
|
43
|
+
- `PostToolUse`, `UserPromptSubmit`, `SubagentStart`, `PreCompact`, and
|
|
44
|
+
`PostCompact` are observe-only in Elwood's Codex API until Codex documents a
|
|
45
|
+
stable response contract for those hooks.
|
|
46
|
+
- Observe-only or unsupported response states must be rejected by the type system
|
|
47
|
+
and runtime validation. Parent handlers must return `undefined` for no
|
|
48
|
+
decision; an empty object is invalid and must fail open with `hookError`.
|
|
49
|
+
|
|
50
|
+
Decision note: Codex `PreToolUse` and `PermissionRequest` both produce
|
|
51
|
+
event-specific JSON under `hookSpecificOutput`, but they do not use the same
|
|
52
|
+
inner shape. `PreToolUse` uses fields such as `permissionDecision` directly
|
|
53
|
+
beside `hookEventName`; `PermissionRequest` nests `{ behavior, message? }` under
|
|
54
|
+
`decision`. Elwood mirrors the current Codex hook protocol instead of normalizing
|
|
55
|
+
these shapes on the wire.
|
|
56
|
+
|
|
57
|
+
### 7A.3 Readiness
|
|
58
|
+
|
|
59
|
+
For Codex, `Stop` is the canonical signal that a turn completed. If a `Stop`
|
|
60
|
+
handler returns a continuation/blocking decision, Elwood must not mark the
|
|
61
|
+
session ready. Before an unblocked Codex `Stop` marks the session ready, Elwood
|
|
62
|
+
should read the committed live transcript data with one bounded per-pass scan
|
|
63
|
+
(the same bounded read the poll timer performs, never the terminal-drain budget
|
|
64
|
+
reserved for PTY exit) so parent applications receive transcript-derived turn
|
|
65
|
+
activity before the ready transition whenever Codex has already written it.
|
|
66
|
+
|
|
67
|
+
### 7A.4 Non-Hook Transcript Activity
|
|
68
|
+
|
|
69
|
+
Current Codex hooks do not cover every TUI-visible activity. Reasoning/status
|
|
70
|
+
updates and non-shell/non-MCP tools such as web search can appear in Codex's TUI
|
|
71
|
+
and transcript without triggering `PreToolUse` or `PostToolUse`.
|
|
72
|
+
|
|
73
|
+
Elwood must document this limitation and provide a live-only transcript
|
|
74
|
+
observation event. The event may summarize transcript item kind, label, and text,
|
|
75
|
+
and may include the raw item for immediate in-memory rendering. Elwood must not
|
|
76
|
+
persist raw transcript items or derived prompt/tool content in Elwood state.
|
package/prd/08-state.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
## 8. State And Persistence
|
|
2
|
+
|
|
3
|
+
### 8.1 State directory
|
|
4
|
+
|
|
5
|
+
By default, Elwood stores session metadata under:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
<cwd>/.elwood/
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Callers may override this with `stateDir`. Relative `stateDir` values MUST be
|
|
12
|
+
resolved to absolute paths before state is written or read, so equivalent path
|
|
13
|
+
spellings address the same session store. When Elwood initializes the default
|
|
14
|
+
project-local state directory, it should create `.elwood/.gitignore` when that
|
|
15
|
+
file does not already exist. Elwood MUST NOT overwrite an existing gitignore
|
|
16
|
+
file and MUST NOT create gitignore files in caller-provided custom state
|
|
17
|
+
directories unless a future explicit option asks it to.
|
|
18
|
+
The default `.elwood/` directory and generated `.elwood/.gitignore` should be
|
|
19
|
+
readable by normal local tooling, while session-specific subdirectories remain
|
|
20
|
+
private.
|
|
21
|
+
|
|
22
|
+
Before creating or changing state, Elwood MUST reject a symlink at the state
|
|
23
|
+
root, sessions directory, or session directory, and reject user-owned symlink
|
|
24
|
+
ancestors that redirect those paths. System-owned path aliases (such as macOS
|
|
25
|
+
`/tmp`) remain supported. Directory permission changes and generated-file writes
|
|
26
|
+
MUST use no-follow descriptors and verify ownership/type before changing them.
|
|
27
|
+
Unsafe state paths fail as `state_corrupt` before touching the linked target.
|
|
28
|
+
These checks protect against planted checkout paths; they do not isolate a
|
|
29
|
+
hostile process already running as the same OS user.
|
|
30
|
+
|
|
31
|
+
The hook bridge's Unix domain socket MUST NOT live under `stateDir`. Socket
|
|
32
|
+
paths are capped near 104 bytes on macOS (`sockaddr_un.sun_path`), so a socket
|
|
33
|
+
inside a caller-structured `stateDir` breaks any parent app with nested state
|
|
34
|
+
layouts. Instead, each session has a STABLE Elwood-owned private (0700) socket
|
|
35
|
+
home directory under the OS temp dir, named by a bounded, collision-resistant
|
|
36
|
+
fingerprint of the session's full identity — its `stateDir`, adapter, and session
|
|
37
|
+
id — so every start/resume of that session resolves the SAME home even after a
|
|
38
|
+
parent restart (the record persists nothing about it), while two sessions that
|
|
39
|
+
share an explicit session id in different state dirs get DISTINCT homes and can
|
|
40
|
+
never sweep each other's live socket. Each
|
|
41
|
+
launch binds a FRESH socket FILE inside that home, so a stale socket is never
|
|
42
|
+
reused; the socket path is a per-launch runtime value, embedded only in the generated
|
|
43
|
+
private bridge script and never in the session record, so a
|
|
44
|
+
recorded socket path can never be trusted. `teardown` removes the whole socket
|
|
45
|
+
home (every launch's socket) along with the session directory, so no per-launch
|
|
46
|
+
socket can leak undiscoverably across restart/resume cycles. Because `teardown`
|
|
47
|
+
removes the whole home, running two live sessions that share a full identity
|
|
48
|
+
(`stateDir`, adapter, and session id) concurrently is UNSUPPORTED — they would
|
|
49
|
+
also share the session directory, record file, and resume id, and one's teardown
|
|
50
|
+
would remove the other's live socket. A given session identity has at most one
|
|
51
|
+
live session at a time; a failed START (not a teardown) removes only its own
|
|
52
|
+
socket file, so an overlapping failed launch never disturbs a live one. `stateDir`
|
|
53
|
+
length MUST NOT constrain whether a session can start.
|
|
54
|
+
|
|
55
|
+
### 8.2 Session record
|
|
56
|
+
|
|
57
|
+
The schema-version-1 core session record persists only the minimum needed to
|
|
58
|
+
resume or tear down a session after
|
|
59
|
+
the parent app restarts. The persisted record is deliberately small: everything a
|
|
60
|
+
running session needs beyond it is regenerated at each start/resume. Runtime
|
|
61
|
+
bridge scripts contain the fresh IPC token and socket path in owner-only files. The persisted fields are exactly:
|
|
62
|
+
|
|
63
|
+
- schema version;
|
|
64
|
+
- `elwoodSessionId`;
|
|
65
|
+
- adapter kind (`claude` or `codex`);
|
|
66
|
+
- original `cwd`;
|
|
67
|
+
- per-adapter resume state: the CLI's own internal conversation id needed to
|
|
68
|
+
resume, and the resolved launch posture (the privilege and tool-policy options
|
|
69
|
+
the session was launched with — `permissionMode`, `allowedTools`,
|
|
70
|
+
`disallowedTools`, `tools` for Claude; `sandbox`, `approvalPolicy` for Codex),
|
|
71
|
+
so resume re-derives its launch configuration and cannot silently loosen
|
|
72
|
+
privileges.
|
|
73
|
+
|
|
74
|
+
Nothing else is persisted in the core record. In particular it MUST NOT persist session status,
|
|
75
|
+
timestamps, warnings, terminal size, the hook bridge
|
|
76
|
+
authentication token, the socket path, or any Elwood-owned runtime file paths.
|
|
77
|
+
Status and warnings are live-only (§5.7). Runtime file paths are pure functions of
|
|
78
|
+
the state directory, session id, and adapter, so they are DERIVED on demand rather
|
|
79
|
+
than stored. The socket HOME is a deterministic function of that same identity (a
|
|
80
|
+
bounded fingerprint, §8.1), so every launch resolves the same one without storing
|
|
81
|
+
it. The bridge authentication token and the socket FILE inside that home are minted
|
|
82
|
+
FRESH on every start and resume and never trusted from disk — a recorded token or
|
|
83
|
+
socket path is never read back.
|
|
84
|
+
|
|
85
|
+
Elwood-generated session directories MUST be private to the current user
|
|
86
|
+
(`0700`), and generated settings, bridge scripts, and session records MUST be
|
|
87
|
+
written as private files (`0600`) because they can contain local IPC credentials.
|
|
88
|
+
State writes MUST be rename-atomic and fsync-backed where the platform supports
|
|
89
|
+
it. Crash recovery is a product requirement.
|
|
90
|
+
Session records are not intended to be portable across unrelated state
|
|
91
|
+
directories. Session IDs are opaque path components: absolute paths, path
|
|
92
|
+
separators, and traversal segments are invalid.
|
|
93
|
+
|
|
94
|
+
Loop definitions are stored separately so existing schema-version-1 session
|
|
95
|
+
records remain backward-readable. A versioned loop sidecar under the same
|
|
96
|
+
owner-only session directory contains exactly each validated loop's stable ID,
|
|
97
|
+
mode, message, fixed interval when applicable, stable jitter, creation time, and
|
|
98
|
+
expiration time; it contains no timer, due state, submission state, or prior
|
|
99
|
+
phase. An absent sidecar means that the session has no loops. The sidecar uses
|
|
100
|
+
rename-atomic, fsync-backed writes under the same crash-recovery rules and MUST
|
|
101
|
+
be mode `0600`. On every read Elwood rejects malformed data, a sidecar not owned
|
|
102
|
+
by the current user, or any group/world permission bits as `state_corrupt`; it
|
|
103
|
+
must never restore or execute those definitions.
|
|
104
|
+
|
|
105
|
+
### 8.3 What must not be persisted by default
|
|
106
|
+
|
|
107
|
+
Elwood MUST NOT persist raw PTY input/output, ordinary prompts, terminal
|
|
108
|
+
transcripts, hook payloads, hook responses, or conversation content by default.
|
|
109
|
+
The exact prompt in a caller-created loop is an explicit, narrow exception: it
|
|
110
|
+
is durable automation state in the loop sidecar, not an observed chat transcript.
|
|
111
|
+
|
|
112
|
+
Hook events are live-only by default. Terminal data is live-only by default.
|
|
113
|
+
|
|
114
|
+
Parent applications may implement their own logging outside Elwood, but Elwood's
|
|
115
|
+
core state must not become a transcript store.
|
|
116
|
+
|
|
117
|
+
### 8.4 Cleanup
|
|
118
|
+
|
|
119
|
+
Runtime files needed for resume/debugging are kept by default after `stop`.
|
|
120
|
+
Loop definitions are likewise kept after `stop`, unexpected exit, or process
|
|
121
|
+
failure. `kill` permanently removes all loop definitions while preserving the
|
|
122
|
+
ordinary session record; a later resume has no loops.
|
|
123
|
+
|
|
124
|
+
`teardown` removes all Elwood-owned traces for that session, including session
|
|
125
|
+
metadata, generated settings/config, bridge route records, sockets, and
|
|
126
|
+
temporary files. It must not remove agent-owned global transcripts/auth state or
|
|
127
|
+
project/user settings that Elwood did not create.
|