@with-logic/elwood 0.1.1 → 0.1.3

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +30 -3
  2. package/CONTRIBUTING.md +104 -0
  3. package/README.md +43 -31
  4. package/SECURITY.md +42 -0
  5. package/dist/claude/model-picker.d.ts.map +1 -1
  6. package/dist/claude/model-picker.js +4 -3
  7. package/dist/claude/model-picker.js.map +1 -1
  8. package/dist/claude/model-switch-confirmation.js +9 -1
  9. package/dist/claude/model-switch-confirmation.js.map +1 -1
  10. package/dist/claude/screen-table.js +1 -1
  11. package/dist/claude/screen-table.js.map +1 -1
  12. package/dist/claude/session/build.d.ts.map +1 -1
  13. package/dist/claude/session/build.js +3 -0
  14. package/dist/claude/session/build.js.map +1 -1
  15. package/dist/claude/session/start-or-resume.js +1 -0
  16. package/dist/claude/session/start-or-resume.js.map +1 -1
  17. package/dist/cli/help.d.ts +1 -1
  18. package/dist/cli/help.d.ts.map +1 -1
  19. package/dist/cli/help.js +13 -10
  20. package/dist/cli/help.js.map +1 -1
  21. package/dist/cli/interactive/spawn.d.ts.map +1 -1
  22. package/dist/cli/interactive/spawn.js +30 -9
  23. package/dist/cli/interactive/spawn.js.map +1 -1
  24. package/dist/cli/main.d.ts.map +1 -1
  25. package/dist/cli/main.js +9 -0
  26. package/dist/cli/main.js.map +1 -1
  27. package/dist/cli/models/combined.d.ts +6 -0
  28. package/dist/cli/models/combined.d.ts.map +1 -0
  29. package/dist/cli/models/combined.js +92 -0
  30. package/dist/cli/models/combined.js.map +1 -0
  31. package/dist/cli/models/index.d.ts +2 -0
  32. package/dist/cli/models/index.d.ts.map +1 -1
  33. package/dist/cli/models/index.js +21 -7
  34. package/dist/cli/models/index.js.map +1 -1
  35. package/dist/cli/models/render.d.ts +4 -0
  36. package/dist/cli/models/render.d.ts.map +1 -1
  37. package/dist/cli/models/render.js +28 -1
  38. package/dist/cli/models/render.js.map +1 -1
  39. package/dist/cli/models/result.d.ts +13 -0
  40. package/dist/cli/models/result.d.ts.map +1 -0
  41. package/dist/cli/models/result.js +2 -0
  42. package/dist/cli/models/result.js.map +1 -0
  43. package/dist/cli/models/settings.d.ts +61 -0
  44. package/dist/cli/models/settings.d.ts.map +1 -0
  45. package/dist/cli/models/settings.js +24 -0
  46. package/dist/cli/models/settings.js.map +1 -0
  47. package/dist/cli/models/signals.d.ts +11 -0
  48. package/dist/cli/models/signals.d.ts.map +1 -0
  49. package/dist/cli/models/signals.js +21 -0
  50. package/dist/cli/models/signals.js.map +1 -0
  51. package/dist/cli/request/index.js +1 -1
  52. package/dist/cli/request/index.js.map +1 -1
  53. package/dist/cli/request/layers.d.ts +2 -0
  54. package/dist/cli/request/layers.d.ts.map +1 -1
  55. package/dist/cli/request/layers.js +9 -5
  56. package/dist/cli/request/layers.js.map +1 -1
  57. package/dist/cli/request/validation.d.ts.map +1 -1
  58. package/dist/cli/request/validation.js +0 -4
  59. package/dist/cli/request/validation.js.map +1 -1
  60. package/dist/cli/run/output.d.ts.map +1 -1
  61. package/dist/cli/run/output.js +3 -6
  62. package/dist/cli/run/output.js.map +1 -1
  63. package/dist/cli/sessions/index.d.ts +9 -2
  64. package/dist/cli/sessions/index.d.ts.map +1 -1
  65. package/dist/cli/sessions/index.js +2 -6
  66. package/dist/cli/sessions/index.js.map +1 -1
  67. package/dist/cli/sessions/settings.d.ts +11 -0
  68. package/dist/cli/sessions/settings.d.ts.map +1 -0
  69. package/dist/cli/sessions/settings.js +26 -0
  70. package/dist/cli/sessions/settings.js.map +1 -0
  71. package/dist/codex/model-picker.d.ts.map +1 -1
  72. package/dist/codex/model-picker.js +3 -2
  73. package/dist/codex/model-picker.js.map +1 -1
  74. package/dist/codex/session/build.d.ts.map +1 -1
  75. package/dist/codex/session/build.js +5 -7
  76. package/dist/codex/session/build.js.map +1 -1
  77. package/dist/codex/session/start-or-resume.js +1 -0
  78. package/dist/codex/session/start-or-resume.js.map +1 -1
  79. package/dist/core/input/index.d.ts.map +1 -1
  80. package/dist/core/input/index.js +15 -3
  81. package/dist/core/input/index.js.map +1 -1
  82. package/dist/core/models/input.d.ts +3 -0
  83. package/dist/core/models/input.d.ts.map +1 -0
  84. package/dist/core/models/input.js +9 -0
  85. package/dist/core/models/input.js.map +1 -0
  86. package/dist/core/models/picker.d.ts +2 -1
  87. package/dist/core/models/picker.d.ts.map +1 -1
  88. package/dist/core/models/picker.js +6 -5
  89. package/dist/core/models/picker.js.map +1 -1
  90. package/dist/core/models/tui-screen.d.ts +1 -1
  91. package/dist/core/models/tui-screen.d.ts.map +1 -1
  92. package/dist/core/models/tui-screen.js +26 -3
  93. package/dist/core/models/tui-screen.js.map +1 -1
  94. package/dist/runtime/session/base.d.ts.map +1 -1
  95. package/dist/runtime/session/base.js +4 -1
  96. package/dist/runtime/session/base.js.map +1 -1
  97. package/dist/runtime/session/commands.d.ts +2 -1
  98. package/dist/runtime/session/commands.d.ts.map +1 -1
  99. package/dist/runtime/session/commands.js +10 -4
  100. package/dist/runtime/session/commands.js.map +1 -1
  101. package/dist/runtime/session/lifecycle.d.ts +3 -4
  102. package/dist/runtime/session/lifecycle.d.ts.map +1 -1
  103. package/dist/runtime/session/lifecycle.js +6 -5
  104. package/dist/runtime/session/lifecycle.js.map +1 -1
  105. package/dist/state/directories.d.ts +4 -0
  106. package/dist/state/directories.d.ts.map +1 -0
  107. package/dist/state/directories.js +41 -0
  108. package/dist/state/directories.js.map +1 -0
  109. package/dist/state/files.d.ts.map +1 -1
  110. package/dist/state/files.js +25 -11
  111. package/dist/state/files.js.map +1 -1
  112. package/dist/state/private-session.d.ts.map +1 -1
  113. package/dist/state/private-session.js +2 -0
  114. package/dist/state/private-session.js.map +1 -1
  115. package/dist/state/store.d.ts.map +1 -1
  116. package/dist/state/store.js +7 -1
  117. package/dist/state/store.js.map +1 -1
  118. package/docs/cli-behavior.md +1 -1
  119. package/docs/cli-output.md +40 -10
  120. package/docs/cli.md +3 -2
  121. package/docs/codex/cli.md +57 -0
  122. package/docs/codex/config.md +47 -0
  123. package/docs/codex/hooks.md +70 -0
  124. package/examples/README.md +56 -0
  125. package/examples/full.ts +113 -0
  126. package/examples/minimal.ts +15 -0
  127. package/examples/stream.ts +39 -0
  128. package/examples/support.ts +71 -0
  129. package/package.json +10 -5
  130. package/prd/01-overview.md +21 -0
  131. package/prd/02-goals.md +47 -0
  132. package/prd/03-terminology.md +32 -0
  133. package/prd/04-architecture.md +121 -0
  134. package/prd/05-api/5.1-starting-claude.md +108 -0
  135. package/prd/05-api/5.2-resuming-claude.md +86 -0
  136. package/prd/05-api/5.3-claude-session-api.md +658 -0
  137. package/prd/05-api/5.4-events.md +145 -0
  138. package/prd/05-api/5.5-starting-codex.md +106 -0
  139. package/prd/05-api/5.6-resuming-codex.md +48 -0
  140. package/prd/05-api/5.7-codex-session-api.md +425 -0
  141. package/prd/05-api/5.8-session-api.md +122 -0
  142. package/prd/05-api/5.9-loops.md +170 -0
  143. package/prd/05-api/README.md +19 -0
  144. package/prd/06-claude-hook-bridge.md +164 -0
  145. package/prd/07-claude-policy.md +15 -0
  146. package/prd/07a-codex-hook-bridge.md +76 -0
  147. package/prd/08-state.md +127 -0
  148. package/prd/09-lifecycle.md +222 -0
  149. package/prd/10-errors.md +55 -0
  150. package/prd/11-test-app.md +53 -0
  151. package/prd/12-e2e.md +23 -0
  152. package/prd/12a-cli.md +402 -0
  153. package/prd/13-latitude.md +12 -0
  154. package/prd/14-conformance.md +333 -0
  155. package/prd/15-open-notes.md +8 -0
  156. 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.
@@ -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.