@gtc6244/immediacy-daemon 0.1.0
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/dist/src/actions.d.ts +22 -0
- package/dist/src/actions.js +44 -0
- package/dist/src/actions.js.map +1 -0
- package/dist/src/agent/agent.d.ts +89 -0
- package/dist/src/agent/agent.js +22 -0
- package/dist/src/agent/agent.js.map +1 -0
- package/dist/src/agent/claude.d.ts +70 -0
- package/dist/src/agent/claude.js +404 -0
- package/dist/src/agent/claude.js.map +1 -0
- package/dist/src/agent/codex.d.ts +44 -0
- package/dist/src/agent/codex.js +303 -0
- package/dist/src/agent/codex.js.map +1 -0
- package/dist/src/agent/mock.d.ts +31 -0
- package/dist/src/agent/mock.js +155 -0
- package/dist/src/agent/mock.js.map +1 -0
- package/dist/src/agent/model-error.d.ts +8 -0
- package/dist/src/agent/model-error.js +25 -0
- package/dist/src/agent/model-error.js.map +1 -0
- package/dist/src/agent/openrouter.d.ts +38 -0
- package/dist/src/agent/openrouter.js +60 -0
- package/dist/src/agent/openrouter.js.map +1 -0
- package/dist/src/agent/workspace-scope.d.ts +25 -0
- package/dist/src/agent/workspace-scope.js +71 -0
- package/dist/src/agent/workspace-scope.js.map +1 -0
- package/dist/src/allowlist.d.ts +56 -0
- package/dist/src/allowlist.js +141 -0
- package/dist/src/allowlist.js.map +1 -0
- package/dist/src/chatlog.d.ts +50 -0
- package/dist/src/chatlog.js +179 -0
- package/dist/src/chatlog.js.map +1 -0
- package/dist/src/cli/paths.d.ts +40 -0
- package/dist/src/cli/paths.js +76 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/service.d.ts +20 -0
- package/dist/src/cli/service.js +245 -0
- package/dist/src/cli/service.js.map +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +227 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +33 -0
- package/dist/src/config.js +122 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/credentials.d.ts +38 -0
- package/dist/src/credentials.js +120 -0
- package/dist/src/credentials.js.map +1 -0
- package/dist/src/crypto.d.ts +49 -0
- package/dist/src/crypto.js +202 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/daemon-identity.d.ts +31 -0
- package/dist/src/daemon-identity.js +97 -0
- package/dist/src/daemon-identity.js.map +1 -0
- package/dist/src/envelope.d.ts +14 -0
- package/dist/src/envelope.js +28 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/events.d.ts +40 -0
- package/dist/src/events.js +24 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/forward.d.ts +51 -0
- package/dist/src/forward.js +170 -0
- package/dist/src/forward.js.map +1 -0
- package/dist/src/fs-scope.d.ts +17 -0
- package/dist/src/fs-scope.js +47 -0
- package/dist/src/fs-scope.js.map +1 -0
- package/dist/src/github.d.ts +70 -0
- package/dist/src/github.js +178 -0
- package/dist/src/github.js.map +1 -0
- package/dist/src/gitops.d.ts +113 -0
- package/dist/src/gitops.js +557 -0
- package/dist/src/gitops.js.map +1 -0
- package/dist/src/hub.d.ts +50 -0
- package/dist/src/hub.js +92 -0
- package/dist/src/hub.js.map +1 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +56 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/jobs/scheduler.d.ts +94 -0
- package/dist/src/jobs/scheduler.js +373 -0
- package/dist/src/jobs/scheduler.js.map +1 -0
- package/dist/src/jobs/store.d.ts +33 -0
- package/dist/src/jobs/store.js +111 -0
- package/dist/src/jobs/store.js.map +1 -0
- package/dist/src/log.d.ts +61 -0
- package/dist/src/log.js +166 -0
- package/dist/src/log.js.map +1 -0
- package/dist/src/migrate-state-dir.d.ts +1 -0
- package/dist/src/migrate-state-dir.js +47 -0
- package/dist/src/migrate-state-dir.js.map +1 -0
- package/dist/src/models.d.ts +36 -0
- package/dist/src/models.js +71 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/permission-gate.d.ts +48 -0
- package/dist/src/permission-gate.js +93 -0
- package/dist/src/permission-gate.js.map +1 -0
- package/dist/src/permission-scope.d.ts +77 -0
- package/dist/src/permission-scope.js +244 -0
- package/dist/src/permission-scope.js.map +1 -0
- package/dist/src/ports.d.ts +71 -0
- package/dist/src/ports.js +216 -0
- package/dist/src/ports.js.map +1 -0
- package/dist/src/projects.d.ts +83 -0
- package/dist/src/projects.js +205 -0
- package/dist/src/projects.js.map +1 -0
- package/dist/src/protocol.d.ts +1226 -0
- package/dist/src/protocol.js +16 -0
- package/dist/src/protocol.js.map +1 -0
- package/dist/src/repo-sync.d.ts +49 -0
- package/dist/src/repo-sync.js +83 -0
- package/dist/src/repo-sync.js.map +1 -0
- package/dist/src/server.d.ts +33 -0
- package/dist/src/server.js +973 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/terminal.d.ts +32 -0
- package/dist/src/terminal.js +92 -0
- package/dist/src/terminal.js.map +1 -0
- package/dist/src/titler.d.ts +15 -0
- package/dist/src/titler.js +72 -0
- package/dist/src/titler.js.map +1 -0
- package/dist/src/types.d.ts +99 -0
- package/dist/src/types.js +3 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/usage-limit.d.ts +49 -0
- package/dist/src/usage-limit.js +83 -0
- package/dist/src/usage-limit.js.map +1 -0
- package/dist/src/usage.d.ts +23 -0
- package/dist/src/usage.js +63 -0
- package/dist/src/usage.js.map +1 -0
- package/dist/src/workspace.d.ts +303 -0
- package/dist/src/workspace.js +1086 -0
- package/dist/src/workspace.js.map +1 -0
- package/dist/src/worktree.d.ts +133 -0
- package/dist/src/worktree.js +561 -0
- package/dist/src/worktree.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-backed agent actions.
|
|
3
|
+
*
|
|
4
|
+
* Each {@link WorkspaceActionKind} is a one-tap workflow the client can trigger
|
|
5
|
+
* on a workspace (review the diff, open a PR, fix build errors, resolve merge
|
|
6
|
+
* conflicts, rename the branch). The daemon doesn't hard-code the behavior: it
|
|
7
|
+
* seeds the workspace's *agent* with a built-in base instruction plus the user's
|
|
8
|
+
* matching `prompts.*` custom text, then lets the agent do the work with its own
|
|
9
|
+
* tools (Read/Edit/Bash/gh) through the normal permission-gated flow.
|
|
10
|
+
*
|
|
11
|
+
* So these consume the previously-reserved prompt config, and adding a new
|
|
12
|
+
* action is just another entry here — no new agent plumbing.
|
|
13
|
+
*/
|
|
14
|
+
import type { PromptsConfig, WorkspaceActionKind } from "./protocol.js";
|
|
15
|
+
/** True for the one action that mutates the branch name (so the manager can
|
|
16
|
+
* re-sync the workspace record afterwards). */
|
|
17
|
+
export declare function actionRenamesBranch(kind: WorkspaceActionKind): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* The full prompt text injected into the agent for an action: the built-in
|
|
20
|
+
* instruction, plus the user's custom prompt for that action when set.
|
|
21
|
+
*/
|
|
22
|
+
export declare function composeActionPrompt(kind: WorkspaceActionKind, prompts: PromptsConfig): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-backed agent actions.
|
|
3
|
+
*
|
|
4
|
+
* Each {@link WorkspaceActionKind} is a one-tap workflow the client can trigger
|
|
5
|
+
* on a workspace (review the diff, open a PR, fix build errors, resolve merge
|
|
6
|
+
* conflicts, rename the branch). The daemon doesn't hard-code the behavior: it
|
|
7
|
+
* seeds the workspace's *agent* with a built-in base instruction plus the user's
|
|
8
|
+
* matching `prompts.*` custom text, then lets the agent do the work with its own
|
|
9
|
+
* tools (Read/Edit/Bash/gh) through the normal permission-gated flow.
|
|
10
|
+
*
|
|
11
|
+
* So these consume the previously-reserved prompt config, and adding a new
|
|
12
|
+
* action is just another entry here — no new agent plumbing.
|
|
13
|
+
*/
|
|
14
|
+
/** Built-in instruction per action; the user's custom prompt is appended. */
|
|
15
|
+
const BASE_INSTRUCTION = {
|
|
16
|
+
code_review: "Review the current changes on this branch versus the base branch. Point out bugs, security issues, and anything that breaks repository conventions; call out missing tests. Do not modify files — just report findings.",
|
|
17
|
+
create_pr: "Open a pull request for this branch using the `gh` CLI. Write a concise title and a body with a summary, the motivation, and a test plan. If a PR already exists for the branch, report its URL instead.",
|
|
18
|
+
fix_errors: "Build and/or run the tests for this project, diagnose whatever is failing, and fix the root cause (not the symptom). Re-run the relevant checks to confirm they pass.",
|
|
19
|
+
resolve_merge_conflicts: "Resolve the outstanding merge conflicts in this worktree. Preserve the intent of both sides; when a resolution is ambiguous, explain the choice you made. Leave the tree in a buildable state.",
|
|
20
|
+
rename_branch: "Choose a short, kebab-case git branch name that summarizes the work on this branch, then rename the current branch to it with `git branch -m <name>`. Report the new name.",
|
|
21
|
+
};
|
|
22
|
+
/** The prompts.* field whose custom text augments each action. */
|
|
23
|
+
const PROMPT_FIELD = {
|
|
24
|
+
code_review: "codeReview",
|
|
25
|
+
create_pr: "createPr",
|
|
26
|
+
fix_errors: "fixErrors",
|
|
27
|
+
resolve_merge_conflicts: "resolveMergeConflicts",
|
|
28
|
+
rename_branch: "renameBranch",
|
|
29
|
+
};
|
|
30
|
+
/** True for the one action that mutates the branch name (so the manager can
|
|
31
|
+
* re-sync the workspace record afterwards). */
|
|
32
|
+
export function actionRenamesBranch(kind) {
|
|
33
|
+
return kind === "rename_branch";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The full prompt text injected into the agent for an action: the built-in
|
|
37
|
+
* instruction, plus the user's custom prompt for that action when set.
|
|
38
|
+
*/
|
|
39
|
+
export function composeActionPrompt(kind, prompts) {
|
|
40
|
+
const custom = prompts[PROMPT_FIELD[kind]]?.trim();
|
|
41
|
+
const base = BASE_INSTRUCTION[kind];
|
|
42
|
+
return custom ? `${base}\n\nAdditional instructions:\n${custom}` : base;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=actions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"actions.js","sourceRoot":"","sources":["../../src/actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,6EAA6E;AAC7E,MAAM,gBAAgB,GAAwC;IAC5D,WAAW,EACT,yNAAyN;IAC3N,SAAS,EACP,0MAA0M;IAC5M,UAAU,EACR,uKAAuK;IACzK,uBAAuB,EACrB,gMAAgM;IAClM,aAAa,EACX,4KAA4K;CAC/K,CAAC;AAEF,kEAAkE;AAClE,MAAM,YAAY,GAAqD;IACrE,WAAW,EAAE,YAAY;IACzB,SAAS,EAAE,UAAU;IACrB,UAAU,EAAE,WAAW;IACvB,uBAAuB,EAAE,uBAAuB;IAChD,aAAa,EAAE,cAAc;CAC9B,CAAC;AAEF;gDACgD;AAChD,MAAM,UAAU,mBAAmB,CAAC,IAAyB;IAC3D,OAAO,IAAI,KAAK,eAAe,CAAC;AAClC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CACjC,IAAyB,EACzB,OAAsB;IAEtB,MAAM,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;IACnD,MAAM,IAAI,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;IACpC,OAAO,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,iCAAiC,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AAC1E,CAAC"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent backend abstraction.
|
|
3
|
+
*
|
|
4
|
+
* A workspace owns exactly one Agent. Two implementations:
|
|
5
|
+
* - ClaudeAgent — real headless Claude via the Agent SDK (default).
|
|
6
|
+
* - MockAgent — scripted, deterministic; real worktree writes + real diff,
|
|
7
|
+
* no credits burned. Used for local/emulator testing and CI.
|
|
8
|
+
*
|
|
9
|
+
* The seam is identical, so the daemon, protocol, permission flow, diff, and UI
|
|
10
|
+
* are all exercised for real regardless of which backend is behind it. Select
|
|
11
|
+
* with AGENT_MODE=claude | mock.
|
|
12
|
+
*/
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
import type { AgentEvent } from "../types.js";
|
|
15
|
+
import type { QuestionAnswers } from "../protocol.js";
|
|
16
|
+
/**
|
|
17
|
+
* How a client can answer a permission request. `allow`/`deny` are one-shot;
|
|
18
|
+
* `allow_all` also persists a category rule (see permission-scope.ts) so future
|
|
19
|
+
* matching calls auto-approve. The gate itself still resolves to a plain
|
|
20
|
+
* {@link PermissionDecision} — the agent turns `allow_all` into a rule + allow.
|
|
21
|
+
*/
|
|
22
|
+
export type PermissionResponse = "allow" | "deny" | "allow_all";
|
|
23
|
+
/**
|
|
24
|
+
* The slice of the daemon-global allowlist an agent needs. AllowlistStore
|
|
25
|
+
* satisfies this structurally, so agent.ts stays free of a store import.
|
|
26
|
+
*/
|
|
27
|
+
export interface PermissionAllowlist {
|
|
28
|
+
/** True when a persisted rule already covers this call. */
|
|
29
|
+
isAllowed(tool: string, input: Record<string, unknown>, worktreePath: string): boolean;
|
|
30
|
+
/** Persist an "allow all" rule for this call (dedup + on-disk). */
|
|
31
|
+
allowFromCall(tool: string, input: Record<string, unknown>, worktreePath: string, fromWorkspace?: string): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
export interface AgentContext {
|
|
34
|
+
workspaceId: string;
|
|
35
|
+
worktreePath: string;
|
|
36
|
+
/** Existing session id to resume, if any (Claude session / Codex thread). */
|
|
37
|
+
resumeSessionId?: string;
|
|
38
|
+
/** Model id to run; backend-specific, undefined → backend default. */
|
|
39
|
+
model?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Custom text (from prompts.general) appended to the session's system prompt.
|
|
42
|
+
* Empty/undefined → no append. Applied by SDK-backed agents (Claude /
|
|
43
|
+
* OpenRouter); backends without a system-prompt seam ignore it.
|
|
44
|
+
*/
|
|
45
|
+
appendSystemPrompt?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Daemon-global "Allow all" list. Consulted before a call defers to the phone
|
|
48
|
+
* (a match auto-approves) and appended to when a client answers `allow_all`.
|
|
49
|
+
* Undefined → no persisted allowlist (every escape still gates).
|
|
50
|
+
*/
|
|
51
|
+
allowlist?: PermissionAllowlist;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A file attached to a prompt (image or document), structurally identical to the
|
|
55
|
+
* wire `PromptAttachment` but kept here so the agent layer doesn't import the
|
|
56
|
+
* protocol. `data` is base64-encoded bytes.
|
|
57
|
+
*/
|
|
58
|
+
export interface AgentAttachment {
|
|
59
|
+
name: string;
|
|
60
|
+
mediaType: string;
|
|
61
|
+
data: string;
|
|
62
|
+
}
|
|
63
|
+
export interface Agent {
|
|
64
|
+
/**
|
|
65
|
+
* Send a user prompt into the (possibly already running) session. Optional
|
|
66
|
+
* `attachments` (images / documents) ride along as model content blocks.
|
|
67
|
+
*/
|
|
68
|
+
prompt(text: string, attachments?: AgentAttachment[]): void;
|
|
69
|
+
/** Resolve a deferred tool-call permission (allow/deny, or allow_all). */
|
|
70
|
+
respondPermission(requestId: string, decision: PermissionResponse): void;
|
|
71
|
+
/**
|
|
72
|
+
* Supply the user's answers to a deferred `AskUserQuestion` call. Backends
|
|
73
|
+
* without an interactive-question seam (mock/codex) ignore it — only the
|
|
74
|
+
* SDK-backed ClaudeAgent gates on it. `response` is an optional freeform reply
|
|
75
|
+
* used in place of the structured `answers`.
|
|
76
|
+
*/
|
|
77
|
+
answerQuestion(requestId: string, answers: QuestionAnswers, response?: string): void;
|
|
78
|
+
/** Tear down the session and deny anything outstanding. */
|
|
79
|
+
stop(): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
/** Agents emit a single typed "event" carrying AgentEvent. */
|
|
82
|
+
export declare abstract class BaseAgent extends EventEmitter implements Agent {
|
|
83
|
+
protected emitEvent(e: AgentEvent): void;
|
|
84
|
+
abstract prompt(text: string, attachments?: AgentAttachment[]): void;
|
|
85
|
+
abstract respondPermission(requestId: string, decision: PermissionResponse): void;
|
|
86
|
+
/** Default no-op; only interactive-question backends (ClaudeAgent) override. */
|
|
87
|
+
answerQuestion(_requestId: string, _answers: QuestionAnswers, _response?: string): void;
|
|
88
|
+
abstract stop(): Promise<void>;
|
|
89
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent backend abstraction.
|
|
3
|
+
*
|
|
4
|
+
* A workspace owns exactly one Agent. Two implementations:
|
|
5
|
+
* - ClaudeAgent — real headless Claude via the Agent SDK (default).
|
|
6
|
+
* - MockAgent — scripted, deterministic; real worktree writes + real diff,
|
|
7
|
+
* no credits burned. Used for local/emulator testing and CI.
|
|
8
|
+
*
|
|
9
|
+
* The seam is identical, so the daemon, protocol, permission flow, diff, and UI
|
|
10
|
+
* are all exercised for real regardless of which backend is behind it. Select
|
|
11
|
+
* with AGENT_MODE=claude | mock.
|
|
12
|
+
*/
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
/** Agents emit a single typed "event" carrying AgentEvent. */
|
|
15
|
+
export class BaseAgent extends EventEmitter {
|
|
16
|
+
emitEvent(e) {
|
|
17
|
+
this.emit("event", e);
|
|
18
|
+
}
|
|
19
|
+
/** Default no-op; only interactive-question backends (ClaudeAgent) override. */
|
|
20
|
+
answerQuestion(_requestId, _answers, _response) { }
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=agent.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent.js","sourceRoot":"","sources":["../../../src/agent/agent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA+E3C,8DAA8D;AAC9D,MAAM,OAAgB,SAAU,SAAQ,YAAY;IACxC,SAAS,CAAC,CAAa;QAC/B,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;IACxB,CAAC;IAGD,gFAAgF;IAChF,cAAc,CAAC,UAAkB,EAAE,QAAyB,EAAE,SAAkB,IAAS,CAAC;CAE3F"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeAgent — real headless Claude via the Agent SDK.
|
|
3
|
+
*
|
|
4
|
+
* - Streaming input: prompts are pushed into an async queue so one long-lived
|
|
5
|
+
* session handles many turns (reuse, per §3 billing guidance — no session
|
|
6
|
+
* spam).
|
|
7
|
+
* - Session continuity: the first `system/init` message yields the session id;
|
|
8
|
+
* we persist it for `resume`.
|
|
9
|
+
* - Permissions: `canUseTool` fires before EVERY tool execution. Calls confined
|
|
10
|
+
* to the worktree (the workspace folder) are auto-approved — the same trust
|
|
11
|
+
* boundary Codex enforces with `-s workspace-write`, so bash/scripts/edits
|
|
12
|
+
* inside the workspace just run. Anything that escapes the worktree becomes an
|
|
13
|
+
* `event.permission_request` and blocks on the phone via the shared gate
|
|
14
|
+
* (default-deny on timeout). Set WORKSPACE_AUTO_APPROVE=0 to gate everything.
|
|
15
|
+
* No `bypassPermissions`, ever.
|
|
16
|
+
* - Bandwidth: we emit message-level assistant text only; partial/token deltas
|
|
17
|
+
* stay off (includePartialMessages disabled).
|
|
18
|
+
*/
|
|
19
|
+
import { BaseAgent, AgentContext, AgentAttachment, PermissionResponse } from "./agent.js";
|
|
20
|
+
import type { QuestionAnswers } from "../protocol.js";
|
|
21
|
+
export declare class ClaudeAgent extends BaseAgent {
|
|
22
|
+
private ctx;
|
|
23
|
+
private sdkEnv?;
|
|
24
|
+
private gate;
|
|
25
|
+
/**
|
|
26
|
+
* Deferred `AskUserQuestion` calls, keyed by requestId. Separate from the
|
|
27
|
+
* permission gate because the answer is structured data fed back as the tool
|
|
28
|
+
* result — not an allow/deny — and it waits far longer for the user.
|
|
29
|
+
*/
|
|
30
|
+
private questionGate;
|
|
31
|
+
/**
|
|
32
|
+
* Live gated calls, keyed by requestId, kept only while the phone is deciding.
|
|
33
|
+
* `allow_all` needs the call's (tool, input) to derive the category rule, so
|
|
34
|
+
* we stash it here rather than round-tripping it back from the client.
|
|
35
|
+
*/
|
|
36
|
+
private pendingCalls;
|
|
37
|
+
private inputQueue;
|
|
38
|
+
private inputResolver;
|
|
39
|
+
private closed;
|
|
40
|
+
private started;
|
|
41
|
+
private sessionId?;
|
|
42
|
+
/**
|
|
43
|
+
* @param sdkEnv Optional environment overrides for the Agent SDK subprocess.
|
|
44
|
+
* Used to retarget the same streaming/permission machinery at an
|
|
45
|
+
* Anthropic-compatible endpoint (e.g. OpenRouter — see OpenRouterAgent).
|
|
46
|
+
* The SDK's `env` option *replaces* the subprocess environment, so these
|
|
47
|
+
* are merged over `process.env` at query time (below), never mutating it —
|
|
48
|
+
* a Claude and an OpenRouter workspace can run side by side.
|
|
49
|
+
*/
|
|
50
|
+
constructor(ctx: AgentContext, sdkEnv?: Record<string, string | undefined> | undefined);
|
|
51
|
+
prompt(text: string, attachments?: AgentAttachment[]): void;
|
|
52
|
+
respondPermission(requestId: string, decision: PermissionResponse): void;
|
|
53
|
+
answerQuestion(requestId: string, answers: QuestionAnswers, response?: string): void;
|
|
54
|
+
/**
|
|
55
|
+
* Sweep the still-pending calls after a new "allow all" rule lands and let
|
|
56
|
+
* through any the allowlist now covers, emitting a `permission_resolved` per
|
|
57
|
+
* released call so clients collapse those (no-longer-actionable) cards. Safe
|
|
58
|
+
* to call with no allowlist or nothing pending — it just does nothing.
|
|
59
|
+
*
|
|
60
|
+
* Relies on AllowlistStore.allowFromCall having added the rule to its
|
|
61
|
+
* in-memory map synchronously (before its async persist), so `isAllowed`
|
|
62
|
+
* already reflects it by the time we sweep.
|
|
63
|
+
*/
|
|
64
|
+
private releaseCoveredPending;
|
|
65
|
+
stop(): Promise<void>;
|
|
66
|
+
/** Push-based async iterable of user messages for the SDK's streaming input. */
|
|
67
|
+
private inputStream;
|
|
68
|
+
private runLoop;
|
|
69
|
+
private handleMessage;
|
|
70
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeAgent — real headless Claude via the Agent SDK.
|
|
3
|
+
*
|
|
4
|
+
* - Streaming input: prompts are pushed into an async queue so one long-lived
|
|
5
|
+
* session handles many turns (reuse, per §3 billing guidance — no session
|
|
6
|
+
* spam).
|
|
7
|
+
* - Session continuity: the first `system/init` message yields the session id;
|
|
8
|
+
* we persist it for `resume`.
|
|
9
|
+
* - Permissions: `canUseTool` fires before EVERY tool execution. Calls confined
|
|
10
|
+
* to the worktree (the workspace folder) are auto-approved — the same trust
|
|
11
|
+
* boundary Codex enforces with `-s workspace-write`, so bash/scripts/edits
|
|
12
|
+
* inside the workspace just run. Anything that escapes the worktree becomes an
|
|
13
|
+
* `event.permission_request` and blocks on the phone via the shared gate
|
|
14
|
+
* (default-deny on timeout). Set WORKSPACE_AUTO_APPROVE=0 to gate everything.
|
|
15
|
+
* No `bypassPermissions`, ever.
|
|
16
|
+
* - Bandwidth: we emit message-level assistant text only; partial/token deltas
|
|
17
|
+
* stay off (includePartialMessages disabled).
|
|
18
|
+
*/
|
|
19
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
20
|
+
import { BaseAgent } from "./agent.js";
|
|
21
|
+
import { PermissionGate, QuestionGate } from "../permission-gate.js";
|
|
22
|
+
import { isWorkspaceScoped } from "./workspace-scope.js";
|
|
23
|
+
import { classifyModelError } from "./model-error.js";
|
|
24
|
+
import { detectUsageLimit } from "../usage-limit.js";
|
|
25
|
+
import { extractTurnUsage } from "../usage.js";
|
|
26
|
+
import { logger } from "../log.js";
|
|
27
|
+
import { webRootUrl } from "../permission-scope.js";
|
|
28
|
+
/** Escape hatch: WORKSPACE_AUTO_APPROVE=0 gates every tool call to the phone. */
|
|
29
|
+
const AUTO_APPROVE_WORKSPACE = process.env.WORKSPACE_AUTO_APPROVE !== "0";
|
|
30
|
+
export class ClaudeAgent extends BaseAgent {
|
|
31
|
+
ctx;
|
|
32
|
+
sdkEnv;
|
|
33
|
+
gate = new PermissionGate(120_000);
|
|
34
|
+
/**
|
|
35
|
+
* Deferred `AskUserQuestion` calls, keyed by requestId. Separate from the
|
|
36
|
+
* permission gate because the answer is structured data fed back as the tool
|
|
37
|
+
* result — not an allow/deny — and it waits far longer for the user.
|
|
38
|
+
*/
|
|
39
|
+
questionGate = new QuestionGate();
|
|
40
|
+
/**
|
|
41
|
+
* Live gated calls, keyed by requestId, kept only while the phone is deciding.
|
|
42
|
+
* `allow_all` needs the call's (tool, input) to derive the category rule, so
|
|
43
|
+
* we stash it here rather than round-tripping it back from the client.
|
|
44
|
+
*/
|
|
45
|
+
pendingCalls = new Map();
|
|
46
|
+
inputQueue = [];
|
|
47
|
+
inputResolver = null;
|
|
48
|
+
closed = false;
|
|
49
|
+
started = false;
|
|
50
|
+
sessionId;
|
|
51
|
+
/**
|
|
52
|
+
* @param sdkEnv Optional environment overrides for the Agent SDK subprocess.
|
|
53
|
+
* Used to retarget the same streaming/permission machinery at an
|
|
54
|
+
* Anthropic-compatible endpoint (e.g. OpenRouter — see OpenRouterAgent).
|
|
55
|
+
* The SDK's `env` option *replaces* the subprocess environment, so these
|
|
56
|
+
* are merged over `process.env` at query time (below), never mutating it —
|
|
57
|
+
* a Claude and an OpenRouter workspace can run side by side.
|
|
58
|
+
*/
|
|
59
|
+
constructor(ctx, sdkEnv) {
|
|
60
|
+
super();
|
|
61
|
+
this.ctx = ctx;
|
|
62
|
+
this.sdkEnv = sdkEnv;
|
|
63
|
+
this.sessionId = ctx.resumeSessionId;
|
|
64
|
+
}
|
|
65
|
+
prompt(text, attachments) {
|
|
66
|
+
if (this.closed)
|
|
67
|
+
return;
|
|
68
|
+
this.inputQueue.push({
|
|
69
|
+
type: "user",
|
|
70
|
+
message: { role: "user", content: buildContent(text, attachments) },
|
|
71
|
+
parent_tool_use_id: null,
|
|
72
|
+
});
|
|
73
|
+
this.inputResolver?.();
|
|
74
|
+
this.inputResolver = null;
|
|
75
|
+
if (!this.started) {
|
|
76
|
+
this.started = true;
|
|
77
|
+
void this.runLoop();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
respondPermission(requestId, decision) {
|
|
81
|
+
if (decision === "allow_all") {
|
|
82
|
+
// Persist a category rule from the pending call, then let this one
|
|
83
|
+
// through. If the call can't be generalized (unmodeled shape) or is
|
|
84
|
+
// already gone, this still resolves as a plain allow.
|
|
85
|
+
const call = this.pendingCalls.get(requestId);
|
|
86
|
+
if (call && this.ctx.allowlist) {
|
|
87
|
+
void this.ctx.allowlist
|
|
88
|
+
.allowFromCall(call.tool, call.input, this.ctx.worktreePath, this.ctx.workspaceId)
|
|
89
|
+
.catch((err) => logger.error("model", "persisting allow-all rule failed", {
|
|
90
|
+
ws: this.ctx.workspaceId,
|
|
91
|
+
data: { message: err instanceof Error ? err.message : String(err) },
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
this.pendingCalls.delete(requestId);
|
|
95
|
+
this.gate.resolve(requestId, "allow");
|
|
96
|
+
// The new rule may already cover other calls queued in this same burst
|
|
97
|
+
// (e.g. the model fired eight WebFetches to one site at once). Release
|
|
98
|
+
// them now instead of prompting for each — the whole point of "allow all".
|
|
99
|
+
this.releaseCoveredPending();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.pendingCalls.delete(requestId);
|
|
103
|
+
this.gate.resolve(requestId, decision);
|
|
104
|
+
}
|
|
105
|
+
answerQuestion(requestId, answers, response) {
|
|
106
|
+
this.questionGate.resolve(requestId, { answers, response });
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Sweep the still-pending calls after a new "allow all" rule lands and let
|
|
110
|
+
* through any the allowlist now covers, emitting a `permission_resolved` per
|
|
111
|
+
* released call so clients collapse those (no-longer-actionable) cards. Safe
|
|
112
|
+
* to call with no allowlist or nothing pending — it just does nothing.
|
|
113
|
+
*
|
|
114
|
+
* Relies on AllowlistStore.allowFromCall having added the rule to its
|
|
115
|
+
* in-memory map synchronously (before its async persist), so `isAllowed`
|
|
116
|
+
* already reflects it by the time we sweep.
|
|
117
|
+
*/
|
|
118
|
+
releaseCoveredPending() {
|
|
119
|
+
const allowlist = this.ctx.allowlist;
|
|
120
|
+
if (!allowlist)
|
|
121
|
+
return;
|
|
122
|
+
// Snapshot first: we mutate pendingCalls inside the loop.
|
|
123
|
+
for (const [reqId, call] of [...this.pendingCalls]) {
|
|
124
|
+
if (!allowlist.isAllowed(call.tool, call.input, this.ctx.worktreePath))
|
|
125
|
+
continue;
|
|
126
|
+
this.pendingCalls.delete(reqId);
|
|
127
|
+
this.gate.resolve(reqId, "allow");
|
|
128
|
+
this.emitEvent({ kind: "permission_resolved", requestId: reqId, decision: "allow" });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async stop() {
|
|
132
|
+
this.closed = true;
|
|
133
|
+
this.pendingCalls.clear();
|
|
134
|
+
this.gate.denyAll();
|
|
135
|
+
this.questionGate.denyAll();
|
|
136
|
+
this.inputResolver?.();
|
|
137
|
+
}
|
|
138
|
+
/** Push-based async iterable of user messages for the SDK's streaming input. */
|
|
139
|
+
async *inputStream() {
|
|
140
|
+
while (!this.closed) {
|
|
141
|
+
while (this.inputQueue.length) {
|
|
142
|
+
yield this.inputQueue.shift();
|
|
143
|
+
}
|
|
144
|
+
if (this.closed)
|
|
145
|
+
return;
|
|
146
|
+
await new Promise((r) => (this.inputResolver = r));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async runLoop() {
|
|
150
|
+
const ws = this.ctx.workspaceId;
|
|
151
|
+
const model = this.ctx.model ?? "default";
|
|
152
|
+
const startMs = Date.now();
|
|
153
|
+
let firstMessageSeen = false;
|
|
154
|
+
logger.info("model", "model request started", {
|
|
155
|
+
ws,
|
|
156
|
+
data: { model, resumed: Boolean(this.sessionId) },
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
const response = query({
|
|
160
|
+
prompt: this.inputStream(),
|
|
161
|
+
options: {
|
|
162
|
+
cwd: this.ctx.worktreePath,
|
|
163
|
+
resume: this.sessionId,
|
|
164
|
+
...(this.ctx.model ? { model: this.ctx.model } : {}),
|
|
165
|
+
...(this.sdkEnv ? { env: { ...process.env, ...this.sdkEnv } } : {}),
|
|
166
|
+
// prompts.general (app config): append to Claude Code's own system
|
|
167
|
+
// prompt rather than replacing it. Omitted when empty.
|
|
168
|
+
...(this.ctx.appendSystemPrompt
|
|
169
|
+
? {
|
|
170
|
+
systemPrompt: {
|
|
171
|
+
type: "preset",
|
|
172
|
+
preset: "claude_code",
|
|
173
|
+
append: this.ctx.appendSystemPrompt,
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
: {}),
|
|
177
|
+
includePartialMessages: false,
|
|
178
|
+
// Workspace-scoped calls run without a phone round-trip; anything
|
|
179
|
+
// that escapes the worktree routes through canUseTool → the phone.
|
|
180
|
+
canUseTool: async (toolName, input, opts) => {
|
|
181
|
+
// AskUserQuestion is not a permission gate — it's an interactive
|
|
182
|
+
// multiple-choice prompt whose *answers* become the tool result. Emit
|
|
183
|
+
// a dedicated card, wait for the user's picks, and hand them back as
|
|
184
|
+
// updatedInput (SDK contract: { questions, answers }). No tool_use
|
|
185
|
+
// event, so it never renders as a collapsed generic tool call.
|
|
186
|
+
if (toolName === "AskUserQuestion") {
|
|
187
|
+
const requestId = opts.toolUseID;
|
|
188
|
+
const questions = extractQuestions(input);
|
|
189
|
+
this.emitEvent({ kind: "ask_question", requestId, questions });
|
|
190
|
+
this.emitEvent({ kind: "status", status: "awaiting_permission" });
|
|
191
|
+
const answer = await this.questionGate.wait(requestId);
|
|
192
|
+
this.emitEvent({ kind: "status", status: "running" });
|
|
193
|
+
if (!answer || (Object.keys(answer.answers).length === 0 && !answer.response)) {
|
|
194
|
+
return { behavior: "deny", message: "User did not answer the question." };
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
behavior: "allow",
|
|
198
|
+
updatedInput: {
|
|
199
|
+
questions: input.questions ?? questions,
|
|
200
|
+
answers: answer.answers,
|
|
201
|
+
...(answer.response ? { response: answer.response } : {}),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const inputSummary = opts.title ?? summarize(toolName, input);
|
|
206
|
+
this.emitEvent({ kind: "tool_use", tool: toolName, inputSummary });
|
|
207
|
+
if (AUTO_APPROVE_WORKSPACE &&
|
|
208
|
+
isWorkspaceScoped(toolName, input, this.ctx.worktreePath)) {
|
|
209
|
+
return { behavior: "allow", updatedInput: input };
|
|
210
|
+
}
|
|
211
|
+
// A prior "Allow all" may already cover this category (same host,
|
|
212
|
+
// folder, or tool) — auto-approve without a phone round-trip.
|
|
213
|
+
if (this.ctx.allowlist?.isAllowed(toolName, input, this.ctx.worktreePath)) {
|
|
214
|
+
return { behavior: "allow", updatedInput: input };
|
|
215
|
+
}
|
|
216
|
+
const requestId = opts.toolUseID;
|
|
217
|
+
this.pendingCalls.set(requestId, { tool: toolName, input });
|
|
218
|
+
this.emitEvent({ kind: "status", status: "awaiting_permission" });
|
|
219
|
+
this.emitEvent({ kind: "permission", requestId, tool: toolName, inputSummary });
|
|
220
|
+
const decision = await this.gate.wait(requestId);
|
|
221
|
+
this.pendingCalls.delete(requestId);
|
|
222
|
+
this.emitEvent({ kind: "status", status: "running" });
|
|
223
|
+
return decision === "allow"
|
|
224
|
+
? { behavior: "allow", updatedInput: input }
|
|
225
|
+
: { behavior: "deny", message: "Denied from phone" };
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
this.emitEvent({ kind: "status", status: "running" });
|
|
230
|
+
for await (const msg of response) {
|
|
231
|
+
if (this.closed)
|
|
232
|
+
break;
|
|
233
|
+
if (!firstMessageSeen) {
|
|
234
|
+
firstMessageSeen = true;
|
|
235
|
+
// First inbound SDK message = the model is reachable and answering.
|
|
236
|
+
logger.info("model", "model reachable", {
|
|
237
|
+
ws,
|
|
238
|
+
data: { model, latencyMs: Date.now() - startMs },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
this.handleMessage(msg);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
const cls = classifyModelError(err);
|
|
246
|
+
logger.error("model", "model request failed", {
|
|
247
|
+
ws,
|
|
248
|
+
data: {
|
|
249
|
+
model,
|
|
250
|
+
class: cls,
|
|
251
|
+
reachedModel: firstMessageSeen,
|
|
252
|
+
message: err instanceof Error ? err.message : String(err),
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
256
|
+
// A plan/session usage limit is a distinct, actionable case: strict
|
|
257
|
+
// matching keeps a transient 429/overload (also class `rate_limit`) out of
|
|
258
|
+
// the restart flow — only a real usage-limit notice surfaces the card.
|
|
259
|
+
const limit = detectUsageLimit(message, { strict: true });
|
|
260
|
+
if (limit) {
|
|
261
|
+
this.emitEvent({ kind: "limit", message: limit.message, resetAt: limit.resetAt });
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
this.emitEvent({ kind: "error", message });
|
|
265
|
+
}
|
|
266
|
+
this.emitEvent({ kind: "status", status: "error" });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
handleMessage(msg) {
|
|
270
|
+
const m = msg;
|
|
271
|
+
switch (m.type) {
|
|
272
|
+
case "system": {
|
|
273
|
+
if (m.subtype === "init" && typeof m.session_id === "string") {
|
|
274
|
+
this.sessionId = m.session_id;
|
|
275
|
+
this.emitEvent({ kind: "session", sessionId: m.session_id });
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
case "assistant": {
|
|
280
|
+
const content = m.message?.content;
|
|
281
|
+
const text = extractText(content);
|
|
282
|
+
if (text)
|
|
283
|
+
this.emitEvent({ kind: "assistant", text });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
case "result": {
|
|
287
|
+
const summary = typeof m.result === "string" ? m.result : undefined;
|
|
288
|
+
// A limit can arrive as an error result rather than a thrown exception.
|
|
289
|
+
// Strict matching is safe against a successful turn's own result text.
|
|
290
|
+
const isError = typeof m.subtype === "string" && m.subtype !== "success";
|
|
291
|
+
const limit = isError ? detectUsageLimit(summary, { strict: true }) : null;
|
|
292
|
+
if (limit) {
|
|
293
|
+
this.emitEvent({ kind: "limit", message: limit.message, resetAt: limit.resetAt });
|
|
294
|
+
this.emitEvent({ kind: "status", status: "error" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.emitEvent({ kind: "done", summary, usage: extractTurnUsage(m) });
|
|
298
|
+
this.emitEvent({ kind: "status", status: "done" });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
default:
|
|
302
|
+
return; // partial/status/etc. — not forwarded
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Turn a prompt (+ optional attachments) into the SDK user-message `content`.
|
|
308
|
+
*
|
|
309
|
+
* With no attachments this stays a plain string — the original, backward-compatible
|
|
310
|
+
* shape. With attachments it becomes an array of Anthropic content blocks so the
|
|
311
|
+
* model sees the files directly (no tool round-trip):
|
|
312
|
+
* - images → an `image` block (base64 source);
|
|
313
|
+
* - PDFs → a `document` block (base64 source), titled with the filename;
|
|
314
|
+
* - anything else → the bytes decoded as UTF-8 and wrapped in a labelled `text`
|
|
315
|
+
* block, which every model accepts (unlike document blocks).
|
|
316
|
+
*/
|
|
317
|
+
function buildContent(text, attachments) {
|
|
318
|
+
if (!attachments || attachments.length === 0)
|
|
319
|
+
return text;
|
|
320
|
+
const blocks = [];
|
|
321
|
+
if (text.trim().length > 0)
|
|
322
|
+
blocks.push({ type: "text", text });
|
|
323
|
+
for (const a of attachments) {
|
|
324
|
+
if (a.mediaType.startsWith("image/")) {
|
|
325
|
+
blocks.push({
|
|
326
|
+
type: "image",
|
|
327
|
+
source: { type: "base64", media_type: a.mediaType, data: a.data },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
else if (a.mediaType === "application/pdf") {
|
|
331
|
+
blocks.push({
|
|
332
|
+
type: "document",
|
|
333
|
+
title: a.name,
|
|
334
|
+
source: { type: "base64", media_type: "application/pdf", data: a.data },
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const decoded = Buffer.from(a.data, "base64").toString("utf8");
|
|
339
|
+
blocks.push({
|
|
340
|
+
type: "text",
|
|
341
|
+
text: `<attached file: ${a.name}>\n${decoded}\n</attached file>`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// All attachments were empty text and there was no prompt — fall back to a
|
|
346
|
+
// single empty text block so the message is never a zero-block array.
|
|
347
|
+
if (blocks.length === 0)
|
|
348
|
+
blocks.push({ type: "text", text });
|
|
349
|
+
return blocks;
|
|
350
|
+
}
|
|
351
|
+
/** Join text blocks of an assistant message into one string. */
|
|
352
|
+
function extractText(content) {
|
|
353
|
+
if (typeof content === "string")
|
|
354
|
+
return content;
|
|
355
|
+
if (!Array.isArray(content))
|
|
356
|
+
return "";
|
|
357
|
+
return content
|
|
358
|
+
.filter((b) => b && typeof b === "object" && b.type === "text")
|
|
359
|
+
.map((b) => b.text ?? "")
|
|
360
|
+
.join("")
|
|
361
|
+
.trim();
|
|
362
|
+
}
|
|
363
|
+
/** Compact one-line summary of a tool input, for the permission card. */
|
|
364
|
+
function summarize(tool, input) {
|
|
365
|
+
// Web requests (WebFetch, browser/MCP tools) read as a plain "web request to
|
|
366
|
+
// <site>" — the technical tool name, full path/query, and JSON aren't useful
|
|
367
|
+
// on a notification; the root URL is enough to tell what's being reached.
|
|
368
|
+
const url = webRootUrl(input);
|
|
369
|
+
if (url)
|
|
370
|
+
return `Web request to ${url}`;
|
|
371
|
+
const path = input.file_path ?? input.path ?? input.command ?? input.pattern;
|
|
372
|
+
if (typeof path === "string")
|
|
373
|
+
return `${tool}: ${path}`;
|
|
374
|
+
const json = JSON.stringify(input);
|
|
375
|
+
return `${tool}: ${json.length > 80 ? json.slice(0, 77) + "…" : json}`;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Normalize the AskUserQuestion tool input into our wire {@link AgentQuestion}[].
|
|
379
|
+
* Defensive against shape drift: coerces every field and drops nothing silently
|
|
380
|
+
* beyond malformed options, so a client always gets renderable questions.
|
|
381
|
+
*/
|
|
382
|
+
function extractQuestions(input) {
|
|
383
|
+
const raw = input.questions;
|
|
384
|
+
if (!Array.isArray(raw))
|
|
385
|
+
return [];
|
|
386
|
+
return raw.map((q) => {
|
|
387
|
+
const obj = (q ?? {});
|
|
388
|
+
const opts = Array.isArray(obj.options) ? obj.options : [];
|
|
389
|
+
return {
|
|
390
|
+
question: String(obj.question ?? ""),
|
|
391
|
+
header: String(obj.header ?? ""),
|
|
392
|
+
multiSelect: Boolean(obj.multiSelect),
|
|
393
|
+
options: opts.map((o) => {
|
|
394
|
+
const oo = (o ?? {});
|
|
395
|
+
return {
|
|
396
|
+
label: String(oo.label ?? ""),
|
|
397
|
+
description: oo.description != null ? String(oo.description) : undefined,
|
|
398
|
+
preview: typeof oo.preview === "string" ? oo.preview : undefined,
|
|
399
|
+
};
|
|
400
|
+
}),
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
//# sourceMappingURL=claude.js.map
|