@zhijiewang/openharness 2.12.0 → 2.14.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/README.md CHANGED
@@ -317,6 +317,8 @@ hooks:
317
317
 
318
318
  Use `match` to restrict a hook to a specific tool name (e.g., `match: Bash` only triggers for the Bash tool).
319
319
 
320
+ See [docs/hooks.md](docs/hooks.md) for the full event reference including the new `userPromptSubmit`, `permissionRequest`, and `postToolUseFailure` events.
321
+
320
322
  ## Cybergotchi
321
323
 
322
324
  OpenHarness ships with a Tamagotchi-style companion that lives in the side panel. It reacts to your session in real time — celebrating streaks, complaining when tools fail, and getting hungry if you ignore it.
@@ -11,6 +11,7 @@ import { getContextWindow } from "../harness/cost.js";
11
11
  import { normalizeMcpConfig } from "../mcp/config-normalize.js";
12
12
  import { connectedMcpServers } from "../mcp/loader.js";
13
13
  import { getAuthStatus } from "../mcp/oauth.js";
14
+ import { getRouteSelection } from "../providers/router.js";
14
15
  import { mcpLoginHandler, mcpLogoutHandler } from "./mcp-auth.js";
15
16
  export function registerInfoCommands(register, getCommandMap) {
16
17
  register("help", "Show available commands", () => {
@@ -45,6 +46,7 @@ export function registerInfoCommands(register, getCommandMap) {
45
46
  "mcp-login",
46
47
  "mcp-logout",
47
48
  "mcp-registry",
49
+ "router",
48
50
  "init",
49
51
  "bug",
50
52
  "feedback",
@@ -468,6 +470,24 @@ export function registerInfoCommands(register, getCommandMap) {
468
470
  register("mcp-logout", "Wipe local OAuth tokens for an MCP server", async (args) => {
469
471
  return mcpLogoutHandler(args);
470
472
  });
473
+ register("router", "Show the model router state", (_args, ctx) => {
474
+ const cfg = readOhConfig()?.modelRouter;
475
+ const defaultModel = ctx.model ?? "unknown";
476
+ if (!cfg || (!cfg.fast && !cfg.balanced && !cfg.powerful)) {
477
+ return { output: `Router: off (single model: ${defaultModel})`, handled: true };
478
+ }
479
+ const last = ctx.sessionId ? getRouteSelection(ctx.sessionId) : undefined;
480
+ const lines = [
481
+ "Model router:",
482
+ ` fast ${cfg.fast ?? `(default: ${defaultModel})`}`,
483
+ ` balanced ${cfg.balanced ?? `(default: ${defaultModel})`}`,
484
+ ` powerful ${cfg.powerful ?? `(default: ${defaultModel})`}`,
485
+ ];
486
+ if (last) {
487
+ lines.push("", `Last selection: ${last.tier} — "${last.reason}"`);
488
+ }
489
+ return { output: lines.join("\n"), handled: true };
490
+ });
471
491
  register("init", "Initialize project with .oh/ config", () => {
472
492
  const ohDir = join(process.cwd(), ".oh");
473
493
  if (existsSync(ohDir)) {
@@ -7,6 +7,33 @@ import { dirname, join, resolve } from "node:path";
7
7
  import { getContextWindow } from "../harness/cost.js";
8
8
  import { createSession, listSessions, loadSession, saveSession } from "../harness/session.js";
9
9
  import { compressMessages } from "../query/index.js";
10
+ function formatMessagesAsMarkdown(messages) {
11
+ const blocks = [];
12
+ for (const m of messages) {
13
+ if (m.role === "user") {
14
+ blocks.push(`## User\n\n${m.content}`);
15
+ }
16
+ else if (m.role === "assistant") {
17
+ const parts = [];
18
+ if (m.content)
19
+ parts.push(m.content);
20
+ if (m.toolCalls?.length) {
21
+ for (const tc of m.toolCalls) {
22
+ parts.push(`**Tool call:** \`${tc.toolName}(${JSON.stringify(tc.arguments)})\``);
23
+ }
24
+ }
25
+ blocks.push(`## Assistant\n\n${parts.join("\n\n")}`);
26
+ }
27
+ else if (m.role === "tool") {
28
+ for (const tr of m.toolResults ?? []) {
29
+ const label = tr.isError ? "Tool error" : "Tool result";
30
+ blocks.push(`**${label}:**\n\n\`\`\`\n${tr.output}\n\`\`\``);
31
+ }
32
+ }
33
+ // system / info messages are skipped — they're OH-internal UX, not conversation
34
+ }
35
+ return blocks.join("\n\n");
36
+ }
10
37
  function setPinned(args, ctx, pinned) {
11
38
  const idx = parseInt(args.trim(), 10);
12
39
  if (Number.isNaN(idx) || idx < 1 || idx > ctx.messages.length) {
@@ -59,20 +86,19 @@ export function registerSessionCommands(register) {
59
86
  compactedMessages: compacted,
60
87
  };
61
88
  });
62
- register("export", "Export conversation to file", (_args, ctx) => {
63
- const lines = ctx.messages
64
- .filter((m) => m.role === "user" || m.role === "assistant")
65
- .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
66
- .join("\n\n");
67
- const filename = `.oh/export-${ctx.sessionId}.md`;
89
+ register("export", "Export conversation to file (args: 'json' for JSON format)", (args, ctx) => {
90
+ const asJson = args.trim().toLowerCase() === "json";
91
+ const ext = asJson ? "json" : "md";
92
+ const filename = `.oh/export-${ctx.sessionId}.${ext}`;
93
+ const body = asJson ? JSON.stringify(ctx.messages, null, 2) : formatMessagesAsMarkdown(ctx.messages);
68
94
  try {
69
95
  mkdirSync(dirname(filename), { recursive: true });
70
96
  const { writeFileSync } = require("node:fs");
71
- writeFileSync(filename, lines);
72
- return { output: `Exported to ${filename}`, handled: true };
97
+ writeFileSync(filename, body);
98
+ return { output: `Exported ${ctx.messages.length} messages to ${filename}`, handled: true };
73
99
  }
74
100
  catch {
75
- return { output: `Export failed. Content:\n\n${lines.slice(0, 500)}`, handled: true };
101
+ return { output: `Export failed. Content:\n\n${body.slice(0, 500)}`, handled: true };
76
102
  }
77
103
  });
78
104
  register("history", "List recent sessions or search across them", (args) => {
@@ -106,7 +132,8 @@ export function registerSessionCommands(register) {
106
132
  const lines = sessions.map((s) => {
107
133
  const date = new Date(s.updatedAt).toLocaleDateString();
108
134
  const cost = s.cost > 0 ? ` $${s.cost.toFixed(4)}` : "";
109
- return ` ${s.id} ${date} ${String(s.messages).padStart(3)} msgs ${(s.model || "?").slice(0, 24)}${cost}`;
135
+ const parent = s.parentSessionId ? ` ⤴ forked from ${s.parentSessionId}` : "";
136
+ return ` ${s.id} ${date} ${String(s.messages).padStart(3)} msgs ${(s.model || "?").slice(0, 24)}${cost}${parent}`;
110
137
  });
111
138
  return { output: `Recent sessions (use /resume <id> to continue):\n${lines.join("\n")}`, handled: true };
112
139
  });
@@ -127,11 +154,11 @@ export function registerSessionCommands(register) {
127
154
  }
128
155
  });
129
156
  register("fork", "Fork current session (create a branch you can resume later)", (_args, ctx) => {
130
- const forked = createSession("", "");
157
+ const forked = createSession(ctx.providerName, ctx.model, { parentSessionId: ctx.sessionId });
131
158
  forked.messages = [...ctx.messages];
132
159
  saveSession(forked);
133
160
  return {
134
- output: `Session forked as ${forked.id}. Resume later with: oh --resume ${forked.id}`,
161
+ output: `Session forked as ${forked.id} (from ${ctx.sessionId}). Resume later with: oh --resume ${forked.id}`,
135
162
  handled: true,
136
163
  };
137
164
  });
@@ -51,6 +51,9 @@ export type HooksConfig = {
51
51
  sessionEnd?: HookDef[];
52
52
  preToolUse?: HookDef[];
53
53
  postToolUse?: HookDef[];
54
+ postToolUseFailure?: HookDef[];
55
+ userPromptSubmit?: HookDef[];
56
+ permissionRequest?: HookDef[];
54
57
  fileChanged?: HookDef[];
55
58
  cwdChanged?: HookDef[];
56
59
  subagentStart?: HookDef[];
@@ -104,6 +107,10 @@ export type OhConfig = {
104
107
  apiKey?: string;
105
108
  baseUrl?: string;
106
109
  }>;
110
+ /** MCP OAuth token storage backend. Default: "auto" — keychain when available, filesystem otherwise. */
111
+ credentials?: {
112
+ storage?: "filesystem" | "auto";
113
+ };
107
114
  /** Auto-commit after each file-modifying tool execution */
108
115
  gitCommitPerTool?: boolean;
109
116
  /** Effort level for LLM reasoning depth */
@@ -10,7 +10,7 @@
10
10
  * - prompt: LLM yes/no check via provider.complete()
11
11
  */
12
12
  import type { HookDef } from "./config.js";
13
- export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
13
+ export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "postToolUseFailure" | "userPromptSubmit" | "permissionRequest" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
14
14
  export type HookContext = {
15
15
  toolName?: string;
16
16
  toolArgs?: string;
@@ -30,6 +30,14 @@ export type HookContext = {
30
30
  agentId?: string;
31
31
  /** For notification: the message */
32
32
  message?: string;
33
+ /** For userPromptSubmit: the raw prompt text the user is about to submit */
34
+ prompt?: string;
35
+ /** For postToolUseFailure: short error label ("TimeoutError", "ExecutionError", "ReportedError") */
36
+ toolError?: string;
37
+ /** For postToolUseFailure: full error message */
38
+ errorMessage?: string;
39
+ /** For permissionRequest: the decision OH would take absent the hook ("ask", "allow", "deny") — informational */
40
+ permissionAction?: "ask" | "allow" | "deny";
33
41
  };
34
42
  /** Clear hook cache (call after config changes) */
35
43
  export declare function invalidateHookCache(): void;
@@ -58,4 +66,30 @@ export declare function emitHook(event: HookEvent, ctx?: HookContext): boolean;
58
66
  * Supports all hook types (command, HTTP, prompt).
59
67
  */
60
68
  export declare function emitHookAsync(event: HookEvent, ctx?: HookContext): Promise<boolean>;
69
+ /** Parsed shape of a jsonIO hook's stdout JSON response. */
70
+ export type ParsedJsonIoResponse = {
71
+ decision?: "allow" | "deny";
72
+ reason?: string;
73
+ additionalContext?: string;
74
+ permissionDecision?: "allow" | "deny" | "ask";
75
+ };
76
+ /** Parse a hook's stdout as a jsonIO envelope. Returns an empty object on malformed input. */
77
+ export declare function parseJsonIoResponse(raw: string): ParsedJsonIoResponse;
78
+ export type HookOutcome = {
79
+ allowed: boolean;
80
+ additionalContext?: string;
81
+ permissionDecision?: "allow" | "deny" | "ask";
82
+ reason?: string;
83
+ };
84
+ /**
85
+ * Emit a hook event and return a structured HookOutcome parsed from jsonIO responses.
86
+ *
87
+ * Merge semantics:
88
+ * - First `deny` (or `permissionDecision: "deny"`) short-circuits: {allowed: false, ...}.
89
+ * - `permissionDecision: "allow"` short-circuits: {allowed: true, permissionDecision: "allow"}.
90
+ * - `additionalContext` from multiple hooks is concatenated in order, "\n\n" separated.
91
+ * - For NOTIFY_ONLY_OUTCOME_EVENTS (postToolUseFailure), decision/permissionDecision
92
+ * from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
93
+ */
94
+ export declare function emitHookWithOutcome(event: HookEvent, ctx?: HookContext): Promise<HookOutcome>;
61
95
  //# sourceMappingURL=hooks.d.ts.map
@@ -56,6 +56,17 @@ function buildEnv(event, ctx) {
56
56
  env.OH_AGENT_ID = ctx.agentId;
57
57
  if (ctx.message)
58
58
  env.OH_MESSAGE = ctx.message;
59
+ if (ctx.prompt !== undefined) {
60
+ // Cap at 8KB to avoid Windows env-var length limits.
61
+ const PROMPT_MAX = 8 * 1024;
62
+ env.OH_PROMPT = ctx.prompt.length > PROMPT_MAX ? ctx.prompt.slice(0, PROMPT_MAX) : ctx.prompt;
63
+ }
64
+ if (ctx.toolError !== undefined)
65
+ env.OH_TOOL_ERROR = ctx.toolError;
66
+ if (ctx.errorMessage !== undefined)
67
+ env.OH_ERROR_MESSAGE = ctx.errorMessage;
68
+ if (ctx.permissionAction !== undefined)
69
+ env.OH_PERMISSION_ACTION = ctx.permissionAction;
59
70
  return env;
60
71
  }
61
72
  /**
@@ -142,20 +153,15 @@ function runCommandHookAsync(command, env, timeoutMs = 10_000) {
142
153
  });
143
154
  }
144
155
  /**
145
- * Run a JSON-mode command hook (Claude Code convention).
146
- *
147
- * Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
148
- * `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
156
+ * Run a JSON-mode command hook and return the raw stdout string.
149
157
  *
150
- * Gating logic:
151
- * - `decision: "deny"` blocks (returns false).
152
- * - `decision: "allow"` or omitted decision allow (returns true).
153
- * - Non-zero exit code → block.
154
- * - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
155
- * - Timeout or spawn error → block.
158
+ * Rejects (throws) on timeout or spawn error so callers can decide how to
159
+ * interpret the failure. Returns an empty string when stdout is empty.
160
+ * Rejects when the process exits with a non-zero code (callers treat this as
161
+ * a block).
156
162
  */
157
- function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
158
- return new Promise((resolve) => {
163
+ function runJsonIoHookCaptureStdout(command, env, event, ctx, timeoutMs = 10_000) {
164
+ return new Promise((resolve, reject) => {
159
165
  const proc = spawn(command, {
160
166
  shell: true,
161
167
  timeout: timeoutMs,
@@ -168,7 +174,7 @@ function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
168
174
  if (!settled) {
169
175
  settled = true;
170
176
  proc.kill();
171
- resolve(false);
177
+ reject(new Error("hook timed out"));
172
178
  }
173
179
  }, timeoutMs);
174
180
  proc.stdout?.on("data", (chunk) => {
@@ -188,39 +194,56 @@ function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
188
194
  return;
189
195
  settled = true;
190
196
  clearTimeout(timer);
191
- // Non-zero exit is always a block, regardless of stdout.
192
197
  if ((code ?? 1) !== 0) {
193
- resolve(false);
194
- return;
195
- }
196
- // Empty stdout → treat exit code as the signal (allow for exit 0).
197
- if (!stdoutBuf.trim()) {
198
- resolve(true);
198
+ reject(new Error(`hook exited with code ${code ?? 1}`));
199
199
  return;
200
200
  }
201
- try {
202
- const parsed = JSON.parse(stdoutBuf);
203
- if (parsed.decision === "deny") {
204
- resolve(false);
205
- }
206
- else {
207
- resolve(true); // "allow" or omitted → allow
208
- }
209
- }
210
- catch {
211
- // Malformed JSON with a zero exit — fail closed conservatively.
212
- resolve(false);
213
- }
201
+ resolve(stdoutBuf);
214
202
  });
215
- proc.on("error", () => {
203
+ proc.on("error", (err) => {
216
204
  if (!settled) {
217
205
  settled = true;
218
206
  clearTimeout(timer);
219
- resolve(false);
207
+ reject(err);
220
208
  }
221
209
  });
222
210
  });
223
211
  }
212
+ /**
213
+ * Run a JSON-mode command hook (Claude Code convention).
214
+ *
215
+ * Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
216
+ * `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
217
+ *
218
+ * Gating logic:
219
+ * - `decision: "deny"` → blocks (returns false).
220
+ * - `decision: "allow"` or omitted decision → allow (returns true).
221
+ * - Non-zero exit code → block.
222
+ * - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
223
+ * - Timeout or spawn error → block.
224
+ */
225
+ async function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
226
+ let stdout;
227
+ try {
228
+ stdout = await runJsonIoHookCaptureStdout(command, env, event, ctx, timeoutMs);
229
+ }
230
+ catch {
231
+ // timeout, spawn error, or non-zero exit — block
232
+ return false;
233
+ }
234
+ // Empty stdout → treat exit code as the signal (allow for exit 0).
235
+ if (!stdout.trim()) {
236
+ return true;
237
+ }
238
+ try {
239
+ const parsed = JSON.parse(stdout);
240
+ return parsed.decision !== "deny";
241
+ }
242
+ catch {
243
+ // Malformed JSON with a zero exit — fail closed conservatively.
244
+ return false;
245
+ }
246
+ }
224
247
  /** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
225
248
  async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
226
249
  try {
@@ -398,4 +421,150 @@ export async function emitHookAsync(event, ctx = {}) {
398
421
  }
399
422
  return true;
400
423
  }
424
+ /** Parse a hook's stdout as a jsonIO envelope. Returns an empty object on malformed input. */
425
+ export function parseJsonIoResponse(raw) {
426
+ let obj;
427
+ try {
428
+ obj = JSON.parse(raw);
429
+ }
430
+ catch {
431
+ return {};
432
+ }
433
+ if (!obj || typeof obj !== "object" || Array.isArray(obj))
434
+ return {};
435
+ const rec = obj;
436
+ const out = {};
437
+ if (rec.decision === "allow" || rec.decision === "deny")
438
+ out.decision = rec.decision;
439
+ if (typeof rec.reason === "string")
440
+ out.reason = rec.reason;
441
+ const hso = rec.hookSpecificOutput;
442
+ if (hso && typeof hso === "object" && !Array.isArray(hso)) {
443
+ const hsoRec = hso;
444
+ if (typeof hsoRec.additionalContext === "string")
445
+ out.additionalContext = hsoRec.additionalContext;
446
+ if (hsoRec.decision === "allow" || hsoRec.decision === "deny" || hsoRec.decision === "ask") {
447
+ out.permissionDecision = hsoRec.decision;
448
+ }
449
+ if (typeof hsoRec.reason === "string" && !out.reason)
450
+ out.reason = hsoRec.reason;
451
+ }
452
+ return out;
453
+ }
454
+ /** Events for which "notify-only" semantics apply — outcome.allowed is always true. */
455
+ const NOTIFY_ONLY_OUTCOME_EVENTS = new Set(["postToolUseFailure"]);
456
+ /**
457
+ * Map a command-hook's boolean (exit 0 / nonzero) result to a ParsedJsonIoResponse
458
+ * for the given event, applying per-event semantics:
459
+ *
460
+ * - userPromptSubmit: exit 0 → allow ({}); nonzero → deny.
461
+ * - permissionRequest: exit 0 → "ask" (fall through to user); nonzero → deny.
462
+ * - postToolUseFailure: notify-only — exit code is irrelevant, always return {}.
463
+ * - All other events: same as userPromptSubmit (exit 0 allow, nonzero deny).
464
+ */
465
+ function mapEnvExitToOutcome(event, allowed) {
466
+ switch (event) {
467
+ case "permissionRequest":
468
+ return allowed
469
+ ? { permissionDecision: "ask" }
470
+ : { permissionDecision: "deny", decision: "deny", reason: "hook denied (exit code)" };
471
+ case "postToolUseFailure":
472
+ // notify-only; exit code is irrelevant
473
+ return {};
474
+ default:
475
+ return allowed ? {} : { decision: "deny", reason: "hook denied (exit code)" };
476
+ }
477
+ }
478
+ /**
479
+ * Execute a single hook definition and return a ParsedJsonIoResponse for outcome merging.
480
+ * Private to this module — not exported.
481
+ */
482
+ async function runHookForOutcome(def, event, ctx) {
483
+ if (def.jsonIO && def.command) {
484
+ const env = buildEnv(event, ctx);
485
+ let raw;
486
+ try {
487
+ raw = await runJsonIoHookCaptureStdout(def.command, env, event, ctx, def.timeout ?? 10_000);
488
+ }
489
+ catch {
490
+ // timeout, spawn error, non-zero exit — treat as deny for gating events
491
+ return { decision: "deny", reason: "hook failed (timeout or non-zero exit)" };
492
+ }
493
+ if (!raw.trim()) {
494
+ // empty stdout with exit 0 — treat as allow (no decision)
495
+ return {};
496
+ }
497
+ return parseJsonIoResponse(raw);
498
+ }
499
+ if (def.command) {
500
+ // env-var mode — apply per-event exit-code semantics
501
+ const env = buildEnv(event, ctx);
502
+ const code = await runCommandHookAsync(def.command, env, def.timeout ?? 10_000);
503
+ return mapEnvExitToOutcome(event, code === 0);
504
+ }
505
+ if (def.http) {
506
+ const allowed = await runHttpHook(def.http, event, ctx, def.timeout ?? 10_000);
507
+ return mapEnvExitToOutcome(event, allowed);
508
+ }
509
+ if (def.prompt) {
510
+ const allowed = await runPromptHook(def.prompt, ctx, def.timeout ?? 10_000);
511
+ return allowed ? {} : { decision: "deny", reason: "prompt hook denied" };
512
+ }
513
+ return {};
514
+ }
515
+ /**
516
+ * Emit a hook event and return a structured HookOutcome parsed from jsonIO responses.
517
+ *
518
+ * Merge semantics:
519
+ * - First `deny` (or `permissionDecision: "deny"`) short-circuits: {allowed: false, ...}.
520
+ * - `permissionDecision: "allow"` short-circuits: {allowed: true, permissionDecision: "allow"}.
521
+ * - `additionalContext` from multiple hooks is concatenated in order, "\n\n" separated.
522
+ * - For NOTIFY_ONLY_OUTCOME_EVENTS (postToolUseFailure), decision/permissionDecision
523
+ * from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
524
+ */
525
+ export async function emitHookWithOutcome(event, ctx = {}) {
526
+ const hooks = getHooks();
527
+ const list = hooks?.[event];
528
+ if (!list || list.length === 0)
529
+ return { allowed: true };
530
+ const notifyOnly = NOTIFY_ONLY_OUTCOME_EVENTS.has(event);
531
+ const additionalContexts = [];
532
+ let reason;
533
+ let askSeen = false;
534
+ for (const def of list) {
535
+ if (def.match && !matchesHook(def, ctx))
536
+ continue;
537
+ const parsed = await runHookForOutcome(def, event, ctx);
538
+ if (!notifyOnly) {
539
+ if (parsed.decision === "deny" || parsed.permissionDecision === "deny") {
540
+ return {
541
+ allowed: false,
542
+ reason: parsed.reason ?? reason,
543
+ permissionDecision: parsed.permissionDecision,
544
+ };
545
+ }
546
+ if (parsed.permissionDecision === "allow") {
547
+ if (parsed.additionalContext)
548
+ additionalContexts.push(parsed.additionalContext);
549
+ return {
550
+ allowed: true,
551
+ permissionDecision: "allow",
552
+ additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
553
+ };
554
+ }
555
+ if (parsed.permissionDecision === "ask")
556
+ askSeen = true;
557
+ }
558
+ if (parsed.additionalContext)
559
+ additionalContexts.push(parsed.additionalContext);
560
+ if (!reason && parsed.reason)
561
+ reason = parsed.reason;
562
+ }
563
+ return {
564
+ allowed: true,
565
+ additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
566
+ permissionDecision: askSeen ? "ask" : undefined,
567
+ reason,
568
+ };
569
+ }
401
570
  //# sourceMappingURL=hooks.js.map
@@ -13,6 +13,8 @@ export type Session = {
13
13
  gitBranch?: string;
14
14
  workingDir?: string;
15
15
  tools?: string[];
16
+ /** For forked sessions: the session this one was forked from. */
17
+ parentSessionId?: string;
16
18
  /** Hibernate state — saved on exit for wake reconstruction */
17
19
  hibernate?: {
18
20
  summary?: string;
@@ -26,6 +28,7 @@ export declare function createSession(provider: string, model: string, extras?:
26
28
  gitBranch?: string;
27
29
  workingDir?: string;
28
30
  tools?: string[];
31
+ parentSessionId?: string;
29
32
  }): Session;
30
33
  export declare function saveSession(session: Session, dir?: string): string;
31
34
  export declare function loadSession(id: string, dir?: string): Session;
@@ -35,6 +38,7 @@ export declare function listSessions(dir?: string): Array<{
35
38
  messages: number;
36
39
  cost: number;
37
40
  updatedAt: number;
41
+ parentSessionId?: string;
38
42
  }>;
39
43
  /** Returns the ID of the most recently updated session, or null if none exist. */
40
44
  export declare function getLastSessionId(dir?: string): string | null;
@@ -18,6 +18,7 @@ export function createSession(provider, model, extras) {
18
18
  ...(extras?.gitBranch ? { gitBranch: extras.gitBranch } : {}),
19
19
  ...(extras?.workingDir ? { workingDir: extras.workingDir } : {}),
20
20
  ...(extras?.tools ? { tools: extras.tools } : {}),
21
+ ...(extras?.parentSessionId ? { parentSessionId: extras.parentSessionId } : {}),
21
22
  };
22
23
  }
23
24
  let _evicting = false;
@@ -73,6 +74,7 @@ export function listSessions(dir) {
73
74
  messages: data.messages?.length ?? 0,
74
75
  cost: data.totalCost ?? 0,
75
76
  updatedAt: data.updatedAt ?? 0,
77
+ ...(data.parentSessionId ? { parentSessionId: data.parentSessionId } : {}),
76
78
  };
77
79
  }
78
80
  catch {
@@ -6,6 +6,7 @@ import { processSlashCommand } from "../commands/index.js";
6
6
  import { cybergotchiEvents } from "../cybergotchi/events.js";
7
7
  import { resolveMcpMention } from "../mcp/loader.js";
8
8
  import { createInfoMessage, createUserMessage } from "../types/message.js";
9
+ import { emitHookWithOutcome } from "./hooks.js";
9
10
  /**
10
11
  * Process user input: handle exit, companion mentions, slash commands,
11
12
  * @mentions, and prepare the prompt for the LLM.
@@ -78,10 +79,28 @@ export async function handleUserInput(input, ctx) {
78
79
  }
79
80
  if (result.prependToPrompt) {
80
81
  messages = [...messages, createUserMessage(input)];
82
+ const prependPrompt = result.prependToPrompt;
83
+ const prependOutcome = await emitHookWithOutcome("userPromptSubmit", {
84
+ prompt: prependPrompt,
85
+ sessionId: ctx.sessionId,
86
+ model: ctx.currentModel,
87
+ provider: ctx.providerName,
88
+ permissionMode: ctx.permissionMode,
89
+ });
90
+ if (!prependOutcome.allowed) {
91
+ const reason = prependOutcome.reason ? `: ${prependOutcome.reason}` : "";
92
+ return {
93
+ handled: true,
94
+ messages: [...messages, createInfoMessage(`Blocked by userPromptSubmit hook${reason}`)],
95
+ };
96
+ }
97
+ const finalPrependPrompt = prependOutcome.additionalContext
98
+ ? `${prependOutcome.additionalContext}\n\n${prependPrompt}`
99
+ : prependPrompt;
81
100
  return {
82
101
  handled: false,
83
102
  messages,
84
- prompt: result.prependToPrompt,
103
+ prompt: finalPrependPrompt,
85
104
  newModel: result.newModel ?? undefined,
86
105
  };
87
106
  }
@@ -136,6 +155,21 @@ export async function handleUserInput(input, ctx) {
136
155
  /* ignore */
137
156
  }
138
157
  }
139
- return { handled: false, messages, prompt: resolvedInput };
158
+ const outcome = await emitHookWithOutcome("userPromptSubmit", {
159
+ prompt: resolvedInput,
160
+ sessionId: ctx.sessionId,
161
+ model: ctx.currentModel,
162
+ provider: ctx.providerName,
163
+ permissionMode: ctx.permissionMode,
164
+ });
165
+ if (!outcome.allowed) {
166
+ const reason = outcome.reason ? `: ${outcome.reason}` : "";
167
+ return {
168
+ handled: true,
169
+ messages: [...messages, createInfoMessage(`Blocked by userPromptSubmit hook${reason}`)],
170
+ };
171
+ }
172
+ const finalPrompt = outcome.additionalContext ? `${outcome.additionalContext}\n\n${resolvedInput}` : resolvedInput;
173
+ return { handled: false, messages, prompt: finalPrompt };
140
174
  }
141
175
  //# sourceMappingURL=submit-handler.js.map