@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +17 -3
  2. package/CONTRIBUTING.md +104 -0
  3. package/README.md +23 -17
  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/interactive/spawn.d.ts.map +1 -1
  18. package/dist/cli/interactive/spawn.js +30 -9
  19. package/dist/cli/interactive/spawn.js.map +1 -1
  20. package/dist/cli/models/index.d.ts.map +1 -1
  21. package/dist/cli/models/index.js +6 -3
  22. package/dist/cli/models/index.js.map +1 -1
  23. package/dist/cli/request/layers.d.ts +2 -0
  24. package/dist/cli/request/layers.d.ts.map +1 -1
  25. package/dist/cli/request/layers.js +9 -5
  26. package/dist/cli/request/layers.js.map +1 -1
  27. package/dist/cli/sessions/index.d.ts +9 -2
  28. package/dist/cli/sessions/index.d.ts.map +1 -1
  29. package/dist/cli/sessions/index.js +2 -6
  30. package/dist/cli/sessions/index.js.map +1 -1
  31. package/dist/cli/sessions/settings.d.ts +11 -0
  32. package/dist/cli/sessions/settings.d.ts.map +1 -0
  33. package/dist/cli/sessions/settings.js +26 -0
  34. package/dist/cli/sessions/settings.js.map +1 -0
  35. package/dist/codex/model-picker.d.ts.map +1 -1
  36. package/dist/codex/model-picker.js +3 -2
  37. package/dist/codex/model-picker.js.map +1 -1
  38. package/dist/codex/session/build.d.ts.map +1 -1
  39. package/dist/codex/session/build.js +5 -7
  40. package/dist/codex/session/build.js.map +1 -1
  41. package/dist/codex/session/start-or-resume.js +1 -0
  42. package/dist/codex/session/start-or-resume.js.map +1 -1
  43. package/dist/core/input/index.d.ts.map +1 -1
  44. package/dist/core/input/index.js +15 -3
  45. package/dist/core/input/index.js.map +1 -1
  46. package/dist/core/models/input.d.ts +3 -0
  47. package/dist/core/models/input.d.ts.map +1 -0
  48. package/dist/core/models/input.js +9 -0
  49. package/dist/core/models/input.js.map +1 -0
  50. package/dist/core/models/picker.d.ts +2 -1
  51. package/dist/core/models/picker.d.ts.map +1 -1
  52. package/dist/core/models/picker.js +6 -5
  53. package/dist/core/models/picker.js.map +1 -1
  54. package/dist/core/models/tui-screen.d.ts +1 -1
  55. package/dist/core/models/tui-screen.d.ts.map +1 -1
  56. package/dist/core/models/tui-screen.js +26 -3
  57. package/dist/core/models/tui-screen.js.map +1 -1
  58. package/dist/runtime/session/base.d.ts.map +1 -1
  59. package/dist/runtime/session/base.js +4 -1
  60. package/dist/runtime/session/base.js.map +1 -1
  61. package/dist/runtime/session/commands.d.ts +2 -1
  62. package/dist/runtime/session/commands.d.ts.map +1 -1
  63. package/dist/runtime/session/commands.js +10 -4
  64. package/dist/runtime/session/commands.js.map +1 -1
  65. package/dist/runtime/session/lifecycle.d.ts +3 -4
  66. package/dist/runtime/session/lifecycle.d.ts.map +1 -1
  67. package/dist/runtime/session/lifecycle.js +6 -5
  68. package/dist/runtime/session/lifecycle.js.map +1 -1
  69. package/dist/state/directories.d.ts +4 -0
  70. package/dist/state/directories.d.ts.map +1 -0
  71. package/dist/state/directories.js +41 -0
  72. package/dist/state/directories.js.map +1 -0
  73. package/dist/state/files.d.ts.map +1 -1
  74. package/dist/state/files.js +25 -11
  75. package/dist/state/files.js.map +1 -1
  76. package/dist/state/private-session.d.ts.map +1 -1
  77. package/dist/state/private-session.js +2 -0
  78. package/dist/state/private-session.js.map +1 -1
  79. package/dist/state/store.d.ts.map +1 -1
  80. package/dist/state/store.js +7 -1
  81. package/dist/state/store.js.map +1 -1
  82. package/docs/cli-behavior.md +1 -1
  83. package/docs/codex/cli.md +57 -0
  84. package/docs/codex/config.md +47 -0
  85. package/docs/codex/hooks.md +70 -0
  86. package/examples/README.md +56 -0
  87. package/examples/full.ts +113 -0
  88. package/examples/minimal.ts +15 -0
  89. package/examples/stream.ts +39 -0
  90. package/examples/support.ts +71 -0
  91. package/package.json +10 -5
  92. package/prd/01-overview.md +21 -0
  93. package/prd/02-goals.md +47 -0
  94. package/prd/03-terminology.md +32 -0
  95. package/prd/04-architecture.md +121 -0
  96. package/prd/05-api/5.1-starting-claude.md +108 -0
  97. package/prd/05-api/5.2-resuming-claude.md +86 -0
  98. package/prd/05-api/5.3-claude-session-api.md +658 -0
  99. package/prd/05-api/5.4-events.md +145 -0
  100. package/prd/05-api/5.5-starting-codex.md +106 -0
  101. package/prd/05-api/5.6-resuming-codex.md +48 -0
  102. package/prd/05-api/5.7-codex-session-api.md +425 -0
  103. package/prd/05-api/5.8-session-api.md +122 -0
  104. package/prd/05-api/5.9-loops.md +170 -0
  105. package/prd/05-api/README.md +19 -0
  106. package/prd/06-claude-hook-bridge.md +164 -0
  107. package/prd/07-claude-policy.md +15 -0
  108. package/prd/07a-codex-hook-bridge.md +76 -0
  109. package/prd/08-state.md +127 -0
  110. package/prd/09-lifecycle.md +222 -0
  111. package/prd/10-errors.md +55 -0
  112. package/prd/11-test-app.md +53 -0
  113. package/prd/12-e2e.md +23 -0
  114. package/prd/12a-cli.md +375 -0
  115. package/prd/13-latitude.md +12 -0
  116. package/prd/14-conformance.md +333 -0
  117. package/prd/15-open-notes.md +8 -0
  118. package/prd/README.md +45 -0
@@ -0,0 +1,56 @@
1
+ # Elwood Examples
2
+
3
+ ## Minimal: ask and answer (`minimal.ts`)
4
+
5
+ The smallest possible use of Elwood. Construct a session, `send` a message, get the
6
+ assistant's reply back as a string, ask a follow-up. The session starts lazily on the
7
+ first `send`, so there is no start ceremony and no manual "wait for ready".
8
+
9
+ ```sh
10
+ npm run example:minimal
11
+ ```
12
+
13
+ ```ts
14
+ const session = new CodexSession({ autotrust: true });
15
+ const joke = await session.send("Tell me a joke that involves a dog.");
16
+ const rewrite = await session.send("Now rewrite it to be about a cat.");
17
+ await session.close();
18
+ ```
19
+
20
+ ## Streaming: watch the work (`stream.ts`)
21
+
22
+ When you want the intermediate steps, `stream` yields typed events (`thinking`,
23
+ `tool_call`, `tool_result`, `text`) as they arrive. `send` is just this generator
24
+ drained for the `text`, so the two share one turn boundary.
25
+
26
+ ```sh
27
+ npm run example:stream
28
+ ```
29
+
30
+ ```ts
31
+ for await (const event of session.stream("List the files, then summarize the project.")) {
32
+ if (event.type === "text") process.stdout.write(event.text);
33
+ // event.type is also "thinking" | "tool_call" | "tool_result"
34
+ }
35
+ ```
36
+
37
+ ## Full: the raw control surface (`full.ts`)
38
+
39
+ `full.ts` drops below the ergonomic facade to the low-level API: `startClaude`/
40
+ `startCodex`, raw `activity`/`warning`/`terminal:exit` events, an explicit
41
+ `waitForSettled` helper (from `support.ts`) that watches `status` and
42
+ `terminal:exit`, and CLI flags for agent/cwd/prompt/timeout. Reach for this when you
43
+ need full control over the session lifecycle; most callers want `send`/`stream` above.
44
+
45
+ ```sh
46
+ npm run example:full
47
+ npm run example:full -- --agent codex --cwd . --prompt "Summarize this repo in one paragraph."
48
+ npm run example:full -- --agent claude --cwd . --prompt "Summarize this repo in one paragraph."
49
+ ```
50
+
51
+ All examples use `autotrust: true` so embedded sessions can move past known workspace
52
+ trust prompts. The package scripts run the TypeScript examples under a small Node
53
+ supervisor because `node-pty` owns real PTYs more reliably there and the supervisor can
54
+ kill the example process tree on Ctrl-C. The examples import from `../src/index.ts`
55
+ so they run from a checkout without a build; in your own project import the same
56
+ symbols from `@with-logic/elwood`.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Full runnable Elwood example for starting one agent session, sending one
3
+ * message, logging rich events, and cleaning up the process.
4
+ */
5
+
6
+ import { resolve } from "node:path";
7
+ import { startClaude, startCodex } from "../src/index.ts";
8
+ import {
9
+ type Agent,
10
+ cleanupSession,
11
+ type ExampleSession,
12
+ errorMessage,
13
+ isTerminalStatus,
14
+ textSuffix,
15
+ waitForSettled,
16
+ } from "./support.ts";
17
+
18
+ type Options = {
19
+ readonly agent: Agent;
20
+ readonly cwd: string;
21
+ readonly keep: boolean;
22
+ readonly prompt: string;
23
+ readonly timeoutMs: number;
24
+ };
25
+
26
+ const defaultPrompt = "Say hello from Elwood in one short sentence, then stop.";
27
+ const options = parseArgs(process.argv.slice(2));
28
+ let session: ExampleSession | undefined;
29
+
30
+ try {
31
+ session = await startSession(options);
32
+ wireLogs(session);
33
+ process.stdout.write(`started ${options.agent} session ${session.elwoodSessionId}\n`);
34
+ await session.sendMessage(options.prompt);
35
+ if (!(await waitForSettled(session, options.timeoutMs))) {
36
+ throw new Error(`Timed out after ${options.timeoutMs}ms waiting for the agent to finish.`);
37
+ }
38
+ } catch (error) {
39
+ process.stderr.write(`${errorMessage(error)}\n`);
40
+ process.exitCode = 1;
41
+ } finally {
42
+ if (session && !options.keep && !isTerminalStatus(session.status)) await cleanupSession(session);
43
+ }
44
+
45
+ async function startSession(options: Options): Promise<ExampleSession> {
46
+ const common = { cwd: options.cwd, autotrust: true, initialSize: { cols: 120, rows: 40 } };
47
+ return options.agent === "claude" ? await startClaude(common) : await startCodex(common);
48
+ }
49
+
50
+ function wireLogs(session: ExampleSession): void {
51
+ session.on("activity", (event) => {
52
+ process.stdout.write(
53
+ `[activity] ${event.agent}:${event.kind}:${event.label}${textSuffix(event.text)}\n`,
54
+ );
55
+ });
56
+ session.on("warning", (event) => {
57
+ process.stdout.write(`[warning] ${event.agent}:${event.code} ${event.message}\n`);
58
+ });
59
+ session.on("hookError", (event) => {
60
+ process.stdout.write(`[hookError] ${event.hookEventName}:${event.category} ${event.message}\n`);
61
+ });
62
+ session.on("terminal:exit", (event) => {
63
+ process.stdout.write(`[terminal:exit] code=${event.exitCode}\n`);
64
+ });
65
+ }
66
+
67
+ function parseArgs(args: readonly string[]): Options {
68
+ let agent: Agent = "codex";
69
+ let cwd = process.cwd();
70
+ let keep = false;
71
+ let prompt = defaultPrompt;
72
+ let timeoutMs = 120_000;
73
+ let index = 0;
74
+ while (index < args.length) {
75
+ const arg = args[index];
76
+ if (arg === "--help") return printUsageAndExit();
77
+ if (arg === "--agent")
78
+ [agent, index] = [parseAgent(requireValue(args, index + 1, arg)), index + 2];
79
+ else if (arg === "--cwd")
80
+ [cwd, index] = [resolve(requireValue(args, index + 1, arg)), index + 2];
81
+ else if (arg === "--prompt") [prompt, index] = [requireValue(args, index + 1, arg), index + 2];
82
+ else if (arg === "--timeout-ms")
83
+ [timeoutMs, index] = [Number(requireValue(args, index + 1, arg)), index + 2];
84
+ else if (arg === "--keep") [keep, index] = [true, index + 1];
85
+ else throw new Error(`Unknown option: ${arg}`);
86
+ }
87
+ if (!prompt) throw new Error("--prompt must not be empty.");
88
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0)
89
+ throw new Error("--timeout-ms must be positive.");
90
+ return { agent, cwd: resolve(cwd), keep, prompt, timeoutMs };
91
+ }
92
+
93
+ function requireValue(args: readonly string[], index: number, flag: string): string {
94
+ const value = args[index];
95
+ if (!value) throw new Error(`Missing value for ${flag}.`);
96
+ return value;
97
+ }
98
+
99
+ function parseAgent(value: string): Agent {
100
+ if (value === "claude" || value === "codex") return value;
101
+ throw new Error("--agent must be claude or codex.");
102
+ }
103
+
104
+ function printUsageAndExit(): never {
105
+ process.stdout.write(
106
+ [
107
+ "Usage: npm run example:full",
108
+ ' npm run example:full -- --agent codex --cwd . --prompt "Summarize this repo."',
109
+ "",
110
+ ].join("\n"),
111
+ );
112
+ process.exit(0);
113
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The smallest Elwood example: ask a question, get the answer, ask a follow-up.
3
+ * The ergonomic facade starts Codex lazily on the first `send` and gives back the
4
+ * assistant's text as a string (PRD §5.8).
5
+ */
6
+
7
+ import { CodexSession } from "../src/index.ts";
8
+
9
+ const session = new CodexSession({ autotrust: true });
10
+ try {
11
+ process.stdout.write(`${await session.send("Tell me a joke that involves a dog.")}\n`);
12
+ process.stdout.write(`${await session.send("Now rewrite it to be about a cat.")}\n`);
13
+ } finally {
14
+ await session.close();
15
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Ergonomic streaming: watch a turn's typed events — thinking, tool calls/results, and
3
+ * assistant text — as they arrive, instead of waiting for the final string (PRD §5.8).
4
+ * `send` is just this generator drained for text; `stream` is the same turn, live.
5
+ */
6
+
7
+ import { ClaudeSession } from "../src/index.ts";
8
+
9
+ /** Compile-time exhaustiveness guard: reaching this with a real value means a case is missing. */
10
+ function assertNever(value: never): never {
11
+ throw new Error(`unhandled turn event: ${JSON.stringify(value)}`);
12
+ }
13
+
14
+ const session = new ClaudeSession({ autotrust: true });
15
+ try {
16
+ for await (const event of session.stream("List the files here, then summarize the project.")) {
17
+ switch (event.type) {
18
+ case "thinking":
19
+ process.stderr.write(` (thinking) ${event.text}\n`);
20
+ break;
21
+ case "tool_call":
22
+ process.stdout.write(`→ ${event.name}${event.input ? ` ${event.input}` : ""}\n`);
23
+ break;
24
+ case "tool_result":
25
+ process.stdout.write(`← ${event.output ?? ""}\n`);
26
+ break;
27
+ case "text":
28
+ process.stdout.write(event.text);
29
+ break;
30
+ default:
31
+ // Exhaustiveness: adding a new TurnEvent variant makes this fail to compile, so the
32
+ // canonical example can never silently ignore a newly-surfaced event kind.
33
+ assertNever(event);
34
+ }
35
+ }
36
+ process.stdout.write("\n");
37
+ } finally {
38
+ await session.close();
39
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared helpers for runnable Elwood examples.
3
+ * Implements PRD §11 example behavior.
4
+ */
5
+
6
+ import type {
7
+ ElwoodActivityEvent,
8
+ ElwoodSessionStatus,
9
+ ElwoodWarningEvent,
10
+ HookErrorEvent,
11
+ Unsubscribe,
12
+ } from "../src/index.ts";
13
+
14
+ export type Agent = "claude" | "codex";
15
+ export type ExampleSession = {
16
+ readonly elwoodSessionId: string;
17
+ readonly status: ElwoodSessionStatus;
18
+ on(event: "activity", handler: (event: ElwoodActivityEvent) => void): Unsubscribe;
19
+ on(event: "warning", handler: (event: ElwoodWarningEvent) => void): Unsubscribe;
20
+ on(event: "hookError", handler: (event: HookErrorEvent) => void): Unsubscribe;
21
+ on(
22
+ event: "status",
23
+ handler: (event: { readonly status: ElwoodSessionStatus }) => void,
24
+ ): Unsubscribe;
25
+ on(event: "terminal:exit", handler: (event: { readonly exitCode: number }) => void): Unsubscribe;
26
+ sendMessage(message: string): Promise<void>;
27
+ stop(): Promise<void>;
28
+ kill(): Promise<void>;
29
+ };
30
+
31
+ export function waitForSettled(session: ExampleSession, timeoutMs: number): Promise<boolean> {
32
+ if (session.status === "ready" || isTerminalStatus(session.status)) return Promise.resolve(true);
33
+ return new Promise((resolveWait) => {
34
+ const done = (result: boolean) => {
35
+ clearTimeout(timer);
36
+ for (const unsubscribe of unsubs) unsubscribe();
37
+ resolveWait(result);
38
+ };
39
+ const timer = setTimeout(() => done(false), timeoutMs);
40
+ const unsubs: Unsubscribe[] = [
41
+ session.on("status", (event) => {
42
+ if (event.status === "ready" || isTerminalStatus(event.status)) done(true);
43
+ }),
44
+ session.on("terminal:exit", () => done(true)),
45
+ ];
46
+ });
47
+ }
48
+
49
+ export async function cleanupSession(session: ExampleSession): Promise<void> {
50
+ try {
51
+ await session.stop();
52
+ } catch (error) {
53
+ process.stderr.write(`stop failed, killing session: ${errorMessage(error)}\n`);
54
+ await session.kill();
55
+ }
56
+ }
57
+
58
+ export function isTerminalStatus(status: ElwoodSessionStatus): boolean {
59
+ return (
60
+ status === "stopped" || status === "exited" || status === "killed" || status === "torn_down"
61
+ );
62
+ }
63
+
64
+ export function textSuffix(text: string | undefined): string {
65
+ if (!text) return "";
66
+ return ` ${text.replace(/\s+/g, " ").slice(0, 120)}`;
67
+ }
68
+
69
+ export function errorMessage(error: unknown): string {
70
+ return error instanceof Error ? error.message : String(error);
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@with-logic/elwood",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Control interactive agentic coding CLIs (Claude Code, Codex) through real terminal sessions: typed hooks, a unified activity stream, resumable sessions, and a headless CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Logic App, Inc.",
@@ -26,7 +26,12 @@
26
26
  "type": "module",
27
27
  "files": [
28
28
  "dist",
29
- "docs/*.md",
29
+ "docs/**/*.md",
30
+ "prd/**/*.md",
31
+ "examples/*.ts",
32
+ "examples/README.md",
33
+ "CONTRIBUTING.md",
34
+ "SECURITY.md",
30
35
  "CHANGELOG.md",
31
36
  "README.md"
32
37
  ],
@@ -60,9 +65,9 @@
60
65
  "check:lines": "node --no-warnings scripts/check-lines.ts",
61
66
  "check": "npm run build && npm run typecheck && npm run lint && npm run check:lines && npm test",
62
67
  "check:all": "npm run check && npm run test:e2e",
63
- "test:e2e": "exec node --no-warnings --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-coverage-exclude='src/app/**' --test-coverage-exclude='tests/**' --test-timeout=240000 --test-concurrency=1 --test tests/e2e/*.e2e.ts",
64
- "dev:app": "exec node --no-warnings src/app/dev.ts",
65
- "dev:web": "exec node --no-warnings scripts/supervise.ts src/app/web-dev.ts",
68
+ "test:e2e": "exec node --no-warnings --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-coverage-exclude='tests/**' --test-timeout=240000 --test-concurrency=1 --test tests/e2e/*.e2e.ts",
69
+ "dev:app": "exec node --no-warnings dev/dev.ts",
70
+ "dev:web": "exec node --no-warnings scripts/supervise.ts dev/web/dev.ts",
66
71
  "example:minimal": "exec node --no-warnings scripts/supervise.ts examples/minimal.ts",
67
72
  "example:stream": "exec node --no-warnings scripts/supervise.ts examples/stream.ts",
68
73
  "example:full": "exec node --no-warnings scripts/supervise.ts examples/full.ts"
@@ -0,0 +1,21 @@
1
+ ## 1. Overview
2
+
3
+ Elwood lets an application control an agentic coding CLI that is running in its
4
+ native interactive terminal mode, without forcing a human to operate the TUI.
5
+
6
+ The first supported tool is Claude Code. The second supported tool is OpenAI
7
+ Codex CLI. Elwood starts the selected agent inside a real pseudoterminal,
8
+ configured so the agent sees the same kind of interactive shell session it would
9
+ see if the user opened Terminal.app and ran the CLI manually. Elwood then
10
+ controls the session by writing terminal input, and observes the agent primarily
11
+ through tool-specific hooks routed over local IPC.
12
+
13
+ The core premise is deliberate: the agent should not know it is running in a
14
+ special wrapper. Elwood does not replace Claude Code or Codex with print mode,
15
+ non-interactive mode, SDKs, or custom protocols. It runs the real interactive
16
+ TUI and wraps it.
17
+
18
+ Parent applications can use Elwood headlessly, or attach a terminal renderer
19
+ such as xterm.js to show the live session. A likely parent app is a local
20
+ Tauri-style desktop app that manages many agent sessions, renders their status,
21
+ and lets a user inspect the actual terminal when needed.
@@ -0,0 +1,47 @@
1
+ ## 2. Goals And Non-Goals
2
+
3
+ ### 2.1 Goals
4
+
5
+ - Provide a TypeScript library for starting, controlling, stopping, killing,
6
+ resuming, and tearing down interactive Claude Code and Codex CLI sessions.
7
+ - Run each agent in a real PTY so its TUI behaves as it would in a normal
8
+ terminal.
9
+ - Start Claude from the user's normal macOS interactive login shell environment,
10
+ including startup files such as `~/.zshrc` when that is what Terminal.app
11
+ would load.
12
+ - Route every Claude Code and Codex hook event through Elwood via a generated
13
+ per-session hook bridge.
14
+ - Expose every hook event to the parent app with strong TypeScript types.
15
+ - Let parent hook handlers return event-specific, strongly typed responses that
16
+ map to Claude Code's hook output protocol.
17
+ - Provide a unified agent-control surface where callers can send messages and
18
+ observe a rich chronological activity stream without branching on the
19
+ underlying adapter for common chat-loop workflows.
20
+ - Fail open when no hook handler exists, a handler times out, or a handler
21
+ fails at runtime.
22
+ - Send prompts through terminal input exactly as a human would, including
23
+ multi-line prompts.
24
+ - Persist only session metadata needed for resume and cleanup, plus loop
25
+ definitions that a caller explicitly creates as durable automation state.
26
+ - Avoid persisting raw terminal contents, ordinary user prompts, hook payloads,
27
+ or conversation logs by default. A loop prompt is the narrow exception: its
28
+ exact text is persisted because explicit recurring automation cannot be
29
+ restored without it.
30
+ - Provide a small local test app for manual development and acceptance testing.
31
+
32
+ ### 2.2 Non-Goals
33
+
34
+ - Windows or Linux support in the MVP.
35
+ - Additional non-Claude/non-Codex adapters in the MVP.
36
+ - Using Claude Code print mode (`claude -p`) as the primary control mechanism.
37
+ - Cloud execution, remote hosts, or multi-machine session sync.
38
+ - Full visual parsing of Claude's TUI as the source of truth.
39
+ - Installing or updating Claude Code or Codex, or performing the INITIAL
40
+ authentication for a fresh install. Elwood assumes `claude`/`codex` is
41
+ installed, on `PATH`, and initially authenticated. Elwood DOES detect a lapsed
42
+ Claude login and can drive the interactive `/login` re-authentication flow to
43
+ recover a session whose login expired mid-run (C-CLAUDE-17/18, C-API-43); it
44
+ never performs the human browser sign-in itself.
45
+ - A first-class multi-session manager. Parent apps own orchestration across
46
+ many sessions.
47
+ - Durable audit logging of terminal or hook activity.
@@ -0,0 +1,32 @@
1
+ ## 3. Terminology
2
+
3
+ **Elwood session.** One wrapped interactive agent CLI process. A successfully started
4
+ session owns exactly one main CLI process: a live `ClaudeSessionApi` owns one main
5
+ `claude` process, a live `CodexSessionApi` one main `codex` process. The ergonomic
6
+ `ClaudeSession`/`CodexSession` classes (§5.8) are lazy wrappers that own NO process
7
+ before start and at most one after — each wraps a single such live session.
8
+
9
+ **Elwood session ID.** Stable opaque identifier generated by Elwood and exposed
10
+ to callers as `elwoodSessionId`. Callers use this ID to resume or tear down a
11
+ session. Claude's own session ID is internal metadata.
12
+
13
+ **Agent adapter.** Tool-specific implementation layer. The MVP adapter is
14
+ `claude` and `codex`. Future adapters might support other TUI-based agentic
15
+ CLIs.
16
+
17
+ **PTY session.** The pseudoterminal process and byte streams that make the CLI
18
+ believe it is running in an interactive terminal.
19
+
20
+ **Headless terminal.** Elwood-owned xterm.js terminal instance that renders PTY
21
+ output into a screen model and emits terminal input back to the PTY. This model
22
+ is required even when no UI is shown.
23
+
24
+ **Terminal renderer.** Optional parent-app UI component, such as browser
25
+ xterm.js, that renders the same live PTY stream for users.
26
+
27
+ **Hook bridge.** Elwood-owned command installed into agent hook settings. The
28
+ agent invokes it for hook events; it forwards hook input to the owning Elwood
29
+ runtime over local IPC and returns the runtime's response to the agent.
30
+
31
+ **Parent app.** The application embedding Elwood, commonly a Tauri or Electron
32
+ desktop app.
@@ -0,0 +1,121 @@
1
+ ## 4. Product Architecture
2
+
3
+ ### 4.1 Layers
4
+
5
+ Elwood has three conceptual layers:
6
+
7
+ 1. **PTY layer.** Starts and owns the shell/agent process, terminal input,
8
+ terminal output, resize, graceful stop, force kill, and process status.
9
+ 2. **Agent adapter layer.** Adds tool-specific startup, generated config,
10
+ hook bridge, session metadata, and resume behavior. MVP adapters: Claude Code
11
+ and Codex CLI.
12
+ 3. **Public library layer.** Exposes TypeScript APIs and typed events to parent
13
+ applications.
14
+
15
+ The PTY layer and a headless xterm.js terminal model are required. Elwood MUST
16
+ write PTY output through the headless terminal before inspecting visible TUI
17
+ state, warnings, or startup prompts. Elwood MUST send `sendPrompt` and
18
+ `sendKeys` input through the headless terminal input path so parent app input,
19
+ programmatic prompts, and low-level keys share one terminal control mechanism.
20
+
21
+ ### 4.2 macOS shell behavior
22
+
23
+ On macOS, Elwood MUST start the PTY in the user's configured shell in a way that
24
+ matches the effective environment of opening Terminal.app and running the agent.
25
+ The implementation should use the user's login shell and interactive/login flags
26
+ appropriate for that shell. For the common zsh case, this is expected to behave
27
+ like an interactive login shell and load the user's normal startup files.
28
+
29
+ This interactive login form (`-l -i` for zsh) applies to both the **agent PTY**
30
+ and one-shot, non-PTY preflight probes (`--version`, `update`, `--help`). The
31
+ same shell startup files MUST resolve the CLI for every lifecycle operation so
32
+ preflight, update, launch, and resume cannot operate on different installations.
33
+
34
+ Elwood MUST provide diagnostics or tests proving that environment variables from
35
+ the user's shell startup are visible to the launched agent process.
36
+
37
+ ### 4.3 Claude settings behavior
38
+
39
+ Elwood MUST NOT mutate `.claude/settings.local.json` by default.
40
+
41
+ For each Claude session, Elwood generates a session-scoped settings file or JSON
42
+ object containing only Elwood's necessary overrides:
43
+
44
+ - hook bridge handlers for every Claude hook event;
45
+ - any session-specific environment/settings required for routing.
46
+
47
+ Elwood launches Claude with Claude Code's `--settings <file-or-json>` mechanism
48
+ and relies on Claude Code's native settings merge precedence. Omitted keys in
49
+ Elwood's generated settings must leave user, project, and local settings intact.
50
+ Requested permission mode and allowed/disallowed tool policy may be supplied as
51
+ Claude CLI flags instead of generated settings when Claude exposes a stable flag
52
+ for that policy.
53
+
54
+ Decision note: Claude currently advertises both camelCase and kebab-case aliases
55
+ for tool-policy flags, but Claude's bundled docs and older CLI references name
56
+ `--allowedTools` and `--disallowedTools`. Elwood uses those documented camelCase
57
+ flags to maximize compatibility. The tool rule list is encoded as one
58
+ comma-separated flag value because current `claude --help` explicitly accepts
59
+ comma-separated or space-separated tool lists.
60
+
61
+ Beyond the allow/deny rule pair, `tools` exposes Claude's `--tools` flag — a
62
+ true allowlist over the built-in tool set where only the named tools exist
63
+ and newly added CLI tools default to absent. An empty `tools` array encodes
64
+ `--tools ""`, which Claude documents as disabling all tools. `allowedTools`/
65
+ `disallowedTools` govern permission rules; `tools` governs existence.
66
+
67
+ ### 4.4 Codex configuration behavior
68
+
69
+ Elwood MUST NOT mutate `.codex/config.toml`, `.codex/hooks.json`,
70
+ `~/.codex/config.toml`, or `~/.codex/hooks.json` by default.
71
+
72
+ For each Codex session, Elwood generates only Elwood-owned runtime files under
73
+ the Elwood session metadata directory, then launches Codex with session-scoped
74
+ `--config` overrides that install hook bridge handlers for every supported Codex
75
+ hook event. Elwood reserves `features.hooks=true` and `hookTrust="trust-all"`
76
+ as session-scoped Codex overrides because Codex hooks must be enabled and trusted
77
+ for Elwood to function. User, project, managed, and system Codex config must
78
+ still merge through Codex's normal precedence rules.
79
+
80
+ Decision note: `features.hooks=true` is treated as Elwood-owned configuration,
81
+ not caller configuration. Without it, some Codex versions can parse hook config
82
+ without running hooks, which would make Elwood observe less than the parent app
83
+ expects. Elwood-owned hook command overrides may be emitted before caller
84
+ `configOverrides`, but reserved enablement/trust overrides MUST be emitted after
85
+ caller overrides so callers cannot accidentally disable the bridge.
86
+
87
+ Codex hook trust is a Codex security feature, but Elwood is not useful without
88
+ trusted hooks. Elwood MUST always allow Codex hooks to run. It should use
89
+ `hookTrust="trust-all"` in its session-scoped Codex config and should also use
90
+ Codex's invocation-scoped hook-trust bypass flag when the installed CLI
91
+ advertises it. If Codex still shows an interactive `Hooks need review` prompt,
92
+ Elwood MUST select the numbered option whose label is `Trust all and continue`
93
+ through PTY input; it must not assume that option always has the same number.
94
+ Prompt detection MUST operate on raw PTY output, including ANSI-styled and
95
+ cursor-addressed full-screen output where menu text may arrive as `2.Skip`
96
+ rather than as line-oriented `2. Skip` text.
97
+
98
+ ### 4.5 Environment fidelity
99
+
100
+ An Elwood session MUST behave like the same agent launched from the user's own
101
+ terminal in the same working directory, plus Elwood's additive instrumentation.
102
+ Concretely:
103
+
104
+ - Sessions launch through the user's login+interactive shell in the caller's
105
+ `cwd`, inheriting the parent process environment plus Elwood's own session
106
+ variables (such as `ELWOOD_SESSION_ID`).
107
+ - Elwood MUST NOT modify, move, or shadow user-owned configuration: user and
108
+ project Claude settings, `CLAUDE.md`/`AGENTS.md`, skills, MCP configuration,
109
+ and Codex `config.toml` all load exactly as they would without Elwood.
110
+ - Elwood's hook wiring MUST be additive. User-configured hooks for the same
111
+ events continue to run alongside Elwood's bridge hooks. For Claude this holds
112
+ because `--settings` is a merged settings source; for Codex this holds
113
+ because CLI `-c hooks.*` overrides combine with `config.toml` hook groups
114
+ (verified against codex-cli 0.142).
115
+ - Known deviations MUST be documented rather than silent. Current deviations:
116
+ Codex hook trust is bypassed inside Elwood sessions because Elwood's reserved
117
+ `hookTrust="trust-all"` and trust-bypass flag apply to the whole session, so
118
+ a user hook that Codex would normally prompt about runs without a prompt; and
119
+ caller-provided `settingsOverrides` (§5.1) may intentionally shadow
120
+ project-level Claude settings keys because the generated `--settings` file is
121
+ a high-precedence source.
@@ -0,0 +1,108 @@
1
+ ## 5.1 Starting Claude
2
+
3
+ ```ts
4
+ type StartClaudeOptions = {
5
+ readonly cwd: string;
6
+ readonly stateDir?: string;
7
+ readonly name?: string;
8
+ readonly initialSize?: TerminalSize;
9
+ readonly hooks?: ClaudeHookHandlers;
10
+ readonly persona?: string;
11
+ readonly model?: string;
12
+ readonly reasoningEffort?: ClaudeReasoningEffort;
13
+ readonly permissionMode?: ClaudePermissionMode;
14
+ readonly allowedTools?: readonly ClaudeToolRule[];
15
+ readonly disallowedTools?: readonly ClaudeToolRule[];
16
+ readonly tools?: readonly ClaudeToolRule[];
17
+ readonly settingsOverrides?: ClaudeSettingsOverrides;
18
+ readonly highTrust?: boolean;
19
+ readonly autoupdate?: boolean;
20
+ readonly autotrust?: boolean;
21
+ readonly hookTimeoutMs?: number;
22
+ readonly strictVersionCheck?: boolean;
23
+ };
24
+
25
+ type ClaudeReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
26
+
27
+ declare function startClaude(options: StartClaudeOptions): Promise<ClaudeSessionApi>;
28
+ ```
29
+
30
+ `cwd` is the working directory where Claude should start. `stateDir` overrides
31
+ the default Elwood state directory. `name` is a launch-time convenience forwarded
32
+ to Claude's documented `--name` flag; it is not persisted in Elwood state (§8.2).
33
+ `hooks` registers
34
+ launch-time handlers before Claude starts. When `autoupdate` is true, Elwood
35
+ runs `claude update` from the user's login shell before spawning Claude and
36
+ rechecks the version after the update. The update command runs at most once
37
+ per parent process per adapter, so a parent spawning a roster of sessions at
38
+ launch does not run N concurrent same-binary updates (C-LIFE-09); later
39
+ sessions in the same process reuse the already-updated binary. Separate Elwood
40
+ parent processes for the same macOS user also coordinate through an atomic,
41
+ per-adapter lease in a stable account cache independent of each process's temporary
42
+ directory: one process runs the global updater while contenders wait asynchronously
43
+ for it to finish, then skip their duplicate attempt and re-read the installed
44
+ version. The lease records its owner and generation; an old owner cannot remove a
45
+ successor's lease, a live owner is never evicted solely because of elapsed time,
46
+ and a crashed owner's stale lease is recoverable after a fixed bound longer than
47
+ the update probe's own timeout. `strictVersionCheck` makes unparseable Claude
48
+ versions fatal instead of warning-and-continuing. `permissionMode`
49
+ accepts Claude's documented launch values: `default`, `acceptEdits`, `plan`,
50
+ `auto`, `dontAsk`, and `bypassPermissions`.
51
+
52
+ `highTrust` is the agent-neutral "never ask for permissions" switch. On Claude,
53
+ `highTrust: true` expands to `permissionMode: "bypassPermissions"` before the
54
+ posture is persisted or the process is spawned, so the record, `config
55
+ effective`, and the launched CLI all agree. It is an expansion, not a second
56
+ axis: supplying an explicit `permissionMode` together with `highTrust: true` is a
57
+ configuration error rejected before spawn with the typed
58
+ `claude_high_trust_conflict` error. When Claude starts in bypass-permissions
59
+ mode it may render a one-time acceptance dialog (header `WARNING: Claude Code
60
+ running in Bypass Permissions mode`, affirmative `Yes, I accept`, decline `No,
61
+ exit`); that dialog is an allowlisted trust prompt answered under `autotrust`
62
+ with `startup_prompt` label `bypass_permissions`, and with `autotrust` off it is
63
+ a blocking prompt like any other trust gate. Whether the installed CLI shows the
64
+ dialog at all is version- and machine-dependent (the CLI may persist the
65
+ acceptance), so Elwood answers it when present and otherwise proceeds. When the
66
+ CLI itself refuses bypass mode (for example when running as root), Elwood does
67
+ not special-case it: the refusal surfaces as the ordinary start failure carrying
68
+ the CLI's own message.
69
+
70
+ `model` is forwarded to Claude's documented `--model` launch flag so the
71
+ session starts on the requested model. Elwood does not validate the value;
72
+ Claude owns model-name resolution and its own failure behavior.
73
+
74
+ `reasoningEffort`, when set, is forwarded to Claude's documented `--effort`
75
+ launch flag so the session starts at the requested reasoning effort. Unlike
76
+ `model`, the value is a fixed per-CLI enum, so Elwood validates it against the
77
+ `ClaudeReasoningEffort` set BEFORE spawning and rejects an out-of-enum value with
78
+ the typed `claude_invalid_reasoning_effort` error (listing the valid values), so a
79
+ typo fails fast at start rather than deferring to the CLI. The effort applies to
80
+ the current session only and is NOT persisted by Elwood; a resumed session must
81
+ re-supply it (§5.2), consistent with `model`. Reasoning effort and model are
82
+ independent axes on Claude (the flag is separate from `--model`).
83
+
84
+ `autotrust` is an opt-in convenience for embedded/headless parent apps.
85
+ When true, Elwood detects Claude's first-party workspace trust prompt in the
86
+ rendered terminal and chooses the trust/continue option through PTY input. The
87
+ default is false because trusting a workspace is a security-sensitive decision.
88
+ When Elwood answers the prompt, the session emits adapter-neutral
89
+ `startup_prompt` activity with label `workspace_trust`.
90
+
91
+ Separately from `autotrust`, Elwood MUST dismiss Claude's non-security
92
+ first-party onboarding prompts that would otherwise block readiness in an
93
+ embedded session, always choosing the option that declines or defers the
94
+ offered capability. The currently known prompt of this kind is the browser
95
+ tools onboarding prompt, declined through PTY input (Escape) with
96
+ `startup_prompt` activity emitted under the label `browser_tools`. This
97
+ automation is unconditional because declining grants nothing and an unanswered
98
+ onboarding prompt permanently wedges a headless session.
99
+
100
+ `persona` is an optional instruction message for the agent. When set, Elwood
101
+ enqueues it before control returns to the caller, so it is guaranteed to be the
102
+ session's first submitted user message: it is delivered through the standard
103
+ message queue as soon as the session first becomes ready, ahead of any
104
+ caller-queued messages. Persona delivery is observable through the normal hook
105
+ and activity events, exactly like a caller-submitted message. The persona is
106
+ not persisted in Elwood state (§8.3 applies) and is not re-sent on resume. If
107
+ the session terminates before ever becoming ready, the undelivered persona is
108
+ discarded silently.