@moikapy/lich 0.3.1 → 0.5.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +12 -2
  3. package/dist/{chunk-P52U5M3L.js → chunk-CV2YH3FH.js} +449 -71
  4. package/dist/chunk-CV2YH3FH.js.map +1 -0
  5. package/dist/cli.d.ts +5 -0
  6. package/dist/cli.js +590 -25
  7. package/dist/cli.js.map +1 -1
  8. package/dist/{gateway-CWPVIU3W.js → gateway-W6S43ETE.js} +27 -18
  9. package/dist/gateway-W6S43ETE.js.map +1 -0
  10. package/dist/index.d.ts +58 -11
  11. package/dist/index.js +1 -1
  12. package/dist/{tui-K3EPRXTV.js → tui-DT7XWDTX.js} +8 -5
  13. package/dist/tui-DT7XWDTX.js.map +1 -0
  14. package/docs/architecture/overview.md +8 -6
  15. package/docs/architecture/plugins.md +57 -4
  16. package/docs/architecture/tools.md +16 -4
  17. package/docs/getting-started.md +15 -4
  18. package/docs/index.md +4 -3
  19. package/docs/user-guide/cli.md +24 -3
  20. package/docs/user-guide/godot.md +160 -0
  21. package/docs/user-guide/library.md +4 -2
  22. package/docs/user-guide/plugins.md +79 -5
  23. package/docs/user-guide/tui.md +4 -3
  24. package/examples/game_bridge/README.md +68 -0
  25. package/examples/game_bridge/bridge_io.mjs +60 -0
  26. package/examples/game_bridge/bridge_paths.mjs +11 -0
  27. package/examples/game_bridge/dungeon_memory.mjs +56 -0
  28. package/examples/game_bridge/enemy_actions.mjs +44 -0
  29. package/examples/game_bridge/game_bridge.plugin.mjs +17 -0
  30. package/examples/game_bridge/meteor_veto.mjs +22 -0
  31. package/examples/game_bridge/schemas.mjs +48 -0
  32. package/examples/game_bridge/snapshot.mjs +19 -0
  33. package/examples/game_bridge/validate_order.mjs +44 -0
  34. package/package.json +2 -1
  35. package/dist/chunk-P52U5M3L.js.map +0 -1
  36. package/dist/gateway-CWPVIU3W.js.map +0 -1
  37. package/dist/tui-K3EPRXTV.js.map +0 -1
package/dist/index.d.ts CHANGED
@@ -37,6 +37,8 @@ interface Tool {
37
37
  name: string;
38
38
  description: string;
39
39
  parameters: JsonSchemaObject;
40
+ /** Per-tool executor timeout override in ms; unset tools get the 30s default. */
41
+ timeout_ms?: number;
40
42
  execute(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult>;
41
43
  }
42
44
  interface Toolset {
@@ -55,6 +57,12 @@ interface Toolset {
55
57
  /** Runtime info handed to every hook call. */
56
58
  interface HookContext {
57
59
  work_dir: string;
60
+ /**
61
+ * This plugin's own per-run state sub-map. Every hook invocation receives
62
+ * a ctx exposing only the invoking plugin's bag; the bag is swapped fresh
63
+ * at each run start. Absent only on hand-built contexts outside the runner.
64
+ */
65
+ state?: Map<string, unknown>;
58
66
  }
59
67
  /** Argument passed to before_tool_call hooks. */
60
68
  interface BeforeToolCallInfo {
@@ -69,6 +77,9 @@ interface BeforeToolCallResult {
69
77
  /** Argument passed to after_tool_call hooks. */
70
78
  interface AfterToolCallInfo extends BeforeToolCallInfo {
71
79
  result_summary: string;
80
+ /** Structured executor outcome; gate on this, never parse result_summary. */
81
+ ok: boolean;
82
+ error?: string;
72
83
  }
73
84
  interface RunEndInfo {
74
85
  stopped_reason: string;
@@ -201,6 +212,8 @@ declare class ProviderError extends Error {
201
212
  */
202
213
 
203
214
  declare const agent_config_schema: z.ZodEffects<z.ZodObject<{
215
+ /** Display name used by the TUI banner. */
216
+ agent_name: z.ZodDefault<z.ZodString>;
204
217
  system_prompt: z.ZodOptional<z.ZodString>;
205
218
  max_turns: z.ZodDefault<z.ZodNumber>;
206
219
  providers: z.ZodArray<z.ZodObject<{
@@ -244,9 +257,21 @@ declare const agent_config_schema: z.ZodEffects<z.ZodObject<{
244
257
  terminal_timeout_ms: z.ZodDefault<z.ZodNumber>;
245
258
  /** Plugin entry module specifiers, relative to work_dir or absolute. */
246
259
  plugins: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
260
+ gateway: z.ZodOptional<z.ZodObject<{
261
+ platforms: z.ZodDefault<z.ZodArray<z.ZodEnum<["webhook", "telegram", "discord", "twitch"]>, "many">>;
262
+ /** Env-var names that hold tokens. Never store the secrets themselves. */
263
+ token_envs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
264
+ }, "strip", z.ZodTypeAny, {
265
+ platforms: ("webhook" | "telegram" | "discord" | "twitch")[];
266
+ token_envs: Record<string, string>;
267
+ }, {
268
+ platforms?: ("webhook" | "telegram" | "discord" | "twitch")[] | undefined;
269
+ token_envs?: Record<string, string> | undefined;
270
+ }>>;
247
271
  log_level: z.ZodDefault<z.ZodEnum<["debug", "info", "warn", "error"]>>;
248
272
  }, "strip", z.ZodTypeAny, {
249
273
  plugins: string[];
274
+ agent_name: string;
250
275
  max_turns: number;
251
276
  providers: z.objectOutputType<{
252
277
  kind: z.ZodEnum<["openai_compat", "anthropic", "ollama"]>;
@@ -269,6 +294,10 @@ declare const agent_config_schema: z.ZodEffects<z.ZodObject<{
269
294
  system_prompt?: string | undefined;
270
295
  work_dir?: string | undefined;
271
296
  session_dir?: string | undefined;
297
+ gateway?: {
298
+ platforms: ("webhook" | "telegram" | "discord" | "twitch")[];
299
+ token_envs: Record<string, string>;
300
+ } | undefined;
272
301
  }, {
273
302
  providers: z.objectInputType<{
274
303
  kind: z.ZodEnum<["openai_compat", "anthropic", "ollama"]>;
@@ -284,6 +313,7 @@ declare const agent_config_schema: z.ZodEffects<z.ZodObject<{
284
313
  plugins?: string[] | undefined;
285
314
  temperature?: number | undefined;
286
315
  max_tokens?: number | undefined;
316
+ agent_name?: string | undefined;
287
317
  system_prompt?: string | undefined;
288
318
  max_turns?: number | undefined;
289
319
  work_dir?: string | undefined;
@@ -292,12 +322,17 @@ declare const agent_config_schema: z.ZodEffects<z.ZodObject<{
292
322
  compress_threshold?: number | undefined;
293
323
  session_dir?: string | undefined;
294
324
  terminal_timeout_ms?: number | undefined;
325
+ gateway?: {
326
+ platforms?: ("webhook" | "telegram" | "discord" | "twitch")[] | undefined;
327
+ token_envs?: Record<string, string> | undefined;
328
+ } | undefined;
295
329
  log_level?: "debug" | "info" | "warn" | "error" | undefined;
296
330
  }>, {
297
331
  work_dir: string;
298
332
  providers: ProviderConfig[];
299
333
  session_dir: string;
300
334
  plugins: string[];
335
+ agent_name: string;
301
336
  max_turns: number;
302
337
  tools_enabled: string[] | "all";
303
338
  context_budget_tokens: number;
@@ -307,6 +342,10 @@ declare const agent_config_schema: z.ZodEffects<z.ZodObject<{
307
342
  temperature?: number | undefined;
308
343
  max_tokens?: number | undefined;
309
344
  system_prompt?: string | undefined;
345
+ gateway?: {
346
+ platforms: ("webhook" | "telegram" | "discord" | "twitch")[];
347
+ token_envs: Record<string, string>;
348
+ } | undefined;
310
349
  }, {
311
350
  providers: z.objectInputType<{
312
351
  kind: z.ZodEnum<["openai_compat", "anthropic", "ollama"]>;
@@ -322,6 +361,7 @@ declare const agent_config_schema: z.ZodEffects<z.ZodObject<{
322
361
  plugins?: string[] | undefined;
323
362
  temperature?: number | undefined;
324
363
  max_tokens?: number | undefined;
364
+ agent_name?: string | undefined;
325
365
  system_prompt?: string | undefined;
326
366
  max_turns?: number | undefined;
327
367
  work_dir?: string | undefined;
@@ -330,6 +370,10 @@ declare const agent_config_schema: z.ZodEffects<z.ZodObject<{
330
370
  compress_threshold?: number | undefined;
331
371
  session_dir?: string | undefined;
332
372
  terminal_timeout_ms?: number | undefined;
373
+ gateway?: {
374
+ platforms?: ("webhook" | "telegram" | "discord" | "twitch")[] | undefined;
375
+ token_envs?: Record<string, string> | undefined;
376
+ } | undefined;
333
377
  log_level?: "debug" | "info" | "warn" | "error" | undefined;
334
378
  }>;
335
379
  type AgentConfig = z.infer<typeof agent_config_schema>;
@@ -445,6 +489,7 @@ declare class Agent {
445
489
  private readonly hook_runner;
446
490
  constructor(config: AgentConfig, plugins?: readonly LoadedPlugin[]);
447
491
  run(options: AgentRunOptions): Promise<AgentRunResult>;
492
+ /** Per-run deps: the built-once ToolContext threads through every tool execution. */
448
493
  private loop_deps;
449
494
  /** Best-effort on_run_start fan-out; hook errors are logged, never fatal. */
450
495
  private call_plugin_run_start;
@@ -503,7 +548,11 @@ declare function register_builtin_tools(registry: ToolRegistry, context?: ToolCo
503
548
  *
504
549
  * before_tool_call hooks run in registration order and may veto a call (first
505
550
  * blocker wins; the wrapped executor is never called). Hook errors are warned
506
- * and skipped, never fatal. after_tool_call hooks observe the result summary.
551
+ * and skipped, never fatal. after_tool_call hooks observe the result summary
552
+ * plus the executor's structured ok/error fields. Every hook invocation
553
+ * receives a ctx exposing only its own plugin's state sub-map: the bag is a
554
+ * module-internal WeakMap keyed on the plugin object, swapped fresh at each
555
+ * call_run_start and shared by the lifecycle and per-tool ctx build sites.
507
556
  */
508
557
 
509
558
  /** Structural ToolRunner shape accepted from the wrapped executor. */
@@ -511,27 +560,25 @@ interface WrappedToolRunner {
511
560
  execute(name: string, args: Record<string, unknown>, context?: ToolContext): Promise<ToolResult>;
512
561
  }
513
562
  /**
514
- * All hook arrays are pre-flattened at construction so the per-call hot path
515
- * does no concat; hooks always run in plugin registration order.
563
+ * Hooks stay attached to their plugin (no flattening) so each invocation can
564
+ * be handed a ctx exposing only that plugin's sub-map; hooks always run in
565
+ * plugin registration order.
516
566
  */
517
567
  declare class HookedToolRunner {
518
568
  private readonly wrapped;
519
- private readonly before_hooks;
520
- private readonly after_hooks;
521
- private readonly run_start_hooks;
522
- private readonly run_end_hooks;
523
- constructor(wrapped: WrappedToolRunner, hooks: readonly PluginHooks[]);
569
+ private readonly hooked_plugins;
570
+ constructor(wrapped: WrappedToolRunner, plugins: readonly Plugin[]);
524
571
  /** Run before hooks in order; the first {block: true} verdict wins. */
525
572
  private run_before_hooks;
526
573
  /** Fire-and-forget in spirit but awaited here so runs settle cleanly. */
527
574
  private run_after_hooks;
528
575
  execute(name: string, args: Record<string, unknown>, context?: ToolContext): Promise<ToolResult>;
529
- /** Best-effort on_run_start fan-out used by Agent.run; never throws. */
576
+ /** Best-effort on_run_start fan-out used by Agent.run; never throws. Swaps in a fresh state sub-map per hooked plugin first. */
530
577
  call_run_start(info: {
531
578
  input_chars: number;
532
- }, ctx: HookContext): Promise<void>;
579
+ }, base: HookContext): Promise<void>;
533
580
  /** Best-effort on_run_end fan-out used by Agent.run; never throws. */
534
- call_run_end(info: RunEndInfo, ctx: HookContext): Promise<void>;
581
+ call_run_end(info: RunEndInfo, base: HookContext): Promise<void>;
535
582
  }
536
583
 
537
584
  /**
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  plugin_errors_summary,
16
16
  register_builtin_tools,
17
17
  run_agent
18
- } from "./chunk-P52U5M3L.js";
18
+ } from "./chunk-CV2YH3FH.js";
19
19
  export {
20
20
  Agent,
21
21
  AgentEmitter,
@@ -2,9 +2,9 @@ import {
2
2
  LICH_VERSION
3
3
  } from "./chunk-ZVK3MUPC.js";
4
4
  import {
5
- create_agent,
5
+ create_agent_with_plugins,
6
6
  truncate_text
7
- } from "./chunk-P52U5M3L.js";
7
+ } from "./chunk-CV2YH3FH.js";
8
8
 
9
9
  // src/tui.tsx
10
10
  import { render } from "ink";
@@ -90,6 +90,9 @@ function parse_command(raw_input) {
90
90
  }
91
91
  return { kind: "slash", name: body.slice(0, space_index), args: body.slice(space_index + 1).trim() };
92
92
  }
93
+ function tui_banner_text(agent_name, version, model, kind) {
94
+ return `${agent_name} v${version} \u2014 ${model} (${kind})`;
95
+ }
93
96
  function format_usage(total_tokens) {
94
97
  return total_tokens.toLocaleString("en-US");
95
98
  }
@@ -412,7 +415,7 @@ function TuiApp({ agent }) {
412
415
  );
413
416
  const provider = agent.config.providers[0];
414
417
  return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", minHeight: 8, children: [
415
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: `lich v${LICH_VERSION} \u2014 ${provider?.model ?? "unknown"} (${provider?.kind ?? "unknown"})` }),
418
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: tui_banner_text(agent.config.agent_name, LICH_VERSION, provider?.model ?? "unknown", provider?.kind ?? "unknown") }),
416
419
  /* @__PURE__ */ jsx4(MessageView, { blocks, state }),
417
420
  /* @__PURE__ */ jsx4(StatusBar, { state, model: provider?.model ?? "unknown" }),
418
421
  /* @__PURE__ */ jsx4(CommandBar, { busy: state.phase !== "idle", on_submit: submit })
@@ -422,7 +425,7 @@ function TuiApp({ agent }) {
422
425
  // src/tui.tsx
423
426
  import { jsx as jsx5 } from "react/jsx-runtime";
424
427
  async function run_tui(config) {
425
- const agent = create_agent(config);
428
+ const agent = await create_agent_with_plugins(config);
426
429
  const instance = render(/* @__PURE__ */ jsx5(TuiApp, { agent }));
427
430
  await instance.waitUntilExit();
428
431
  return 0;
@@ -430,4 +433,4 @@ async function run_tui(config) {
430
433
  export {
431
434
  run_tui
432
435
  };
433
- //# sourceMappingURL=tui-K3EPRXTV.js.map
436
+ //# sourceMappingURL=tui-DT7XWDTX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/tui.tsx","../src/tui/app.tsx","../src/tui/state.ts","../src/tui/message_view.tsx","../src/tui/status_bar.tsx","../src/tui/command_bar.tsx"],"sourcesContent":["/**\n * TUI entry: builds the agent from the parsed config and renders the ink app\n * until exit. The CLI dynamic-imports this module for `lich tui`.\n */\nimport { render } from \"ink\";\nimport { create_agent_with_plugins } from \"./agent/agent.js\";\nimport type { AgentConfig } from \"./agent/config.js\";\nimport { TuiApp } from \"./tui/app.js\";\n\nexport async function run_tui(config: AgentConfig): Promise<number> {\n const agent = await create_agent_with_plugins(config);\n const instance = render(<TuiApp agent={agent} />);\n await instance.waitUntilExit();\n return 0;\n}","/**\n * Root ink component for the lich TUI: wires agent events into the UI state\n * machine, drives agent.run with history continuity, and lays out header,\n * transcript, status bar, and the command input row.\n */\nimport { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from \"react\";\nimport { Box, Text } from \"ink\";\nimport type { Agent, AgentRunResult } from \"../agent/agent.js\";\nimport type { AgentEvent } from \"../agent/events.js\";\nimport type { AgentConfig } from \"../agent/config.js\";\nimport type { Message } from \"../providers/types.js\";\nimport { LICH_VERSION } from \"../index.js\";\nimport { readdir, stat } from \"node:fs/promises\";\nimport {\n apply_event,\n apply_run_result,\n compress_notice_block,\n error_notice_block,\n help_block,\n HISTORY_CAP,\n INITIAL_UI_STATE,\n model_label_block,\n parse_command,\n tui_banner_text,\n run_notice_blocks,\n session_list_block,\n tool_result_block,\n unknown_command_block,\n usage_notice_block,\n type HistoryBlock,\n type ParsedInput,\n type SessionEntryInfo,\n type UiState,\n} from \"./state.js\";\nimport { MessageView } from \"./message_view.js\";\nimport { StatusBar } from \"./status_bar.js\";\nimport { CommandBar } from \"./command_bar.js\";\n\nconst SESSION_LIST_CAP = 10;\n\ntype SlashInput = Extract<ParsedInput, { kind: \"slash\" }>;\ntype AddBlocks = (added: readonly HistoryBlock[]) => void;\ntype SetUiState = Dispatch<SetStateAction<UiState>>;\ntype SetBlocks = Dispatch<SetStateAction<readonly HistoryBlock[]>>;\n\n/** Map one agent event to optional transcript blocks (tool rows, notices). */\nfunction event_blocks(event: AgentEvent): readonly HistoryBlock[] {\n if (event.type === \"tool_call_end\") {\n return [tool_result_block(event.call, event.result.ok === true, event.result.output)];\n }\n if (event.type === \"compress_end\") {\n return [compress_notice_block(event.summary_chars)];\n }\n if (event.type === \"error\") {\n return [error_notice_block(event.error instanceof Error ? event.error.message : String(event.error))];\n }\n return [];\n}\n\n/** One agent turn: subscribe to events, run, unsubscribe in finally. */\nasync function run_agent_turn(\n agent: Agent,\n history: readonly Message[],\n input: string,\n on_event: (event: AgentEvent) => void,\n on_done: (result: AgentRunResult) => void,\n signal: AbortSignal,\n): Promise<void> {\n const stop_listening = agent.events.on(on_event);\n try {\n const result = await agent.run({ input, history, signal, label: \"tui\" });\n on_done(result);\n } finally {\n stop_listening();\n }\n}\n\n/** Async /sessions listing as a meta block (never throws). */\nasync function sessions_block(config: AgentConfig): Promise<HistoryBlock> {\n try {\n const dir_entries = await readdir(config.session_dir, { withFileTypes: true });\n const entries: SessionEntryInfo[] = [];\n for (const entry of dir_entries) {\n if (entry.isFile() === false || entry.name.endsWith(\".jsonl\") === false) {\n continue;\n }\n const info = await stat(`${config.session_dir}/${entry.name}`);\n entries.push({ name: entry.name, size_bytes: info.size, mtime_ms: info.mtimeMs });\n }\n return session_list_block(entries, SESSION_LIST_CAP);\n } catch {\n return { role: \"meta\", lines: [\"· no session files yet\"] };\n }\n}\n\nfunction run_error_text(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Runs one message exchange; owns history continuity and abort wiring. */\nfunction use_agent_run(\n agent: Agent,\n add_blocks: AddBlocks,\n set_state: SetUiState,\n set_blocks: SetBlocks,\n): (text: string) => void {\n const history_ref = useRef<readonly Message[]>([]);\n const controller_ref = useRef<AbortController | undefined>(undefined);\n\n const finish_run = useCallback((result: AgentRunResult): void => {\n history_ref.current = result.messages;\n set_state((current) => apply_run_result(current, result));\n add_blocks(run_notice_blocks(result));\n }, [add_blocks, set_state]);\n\n const start_message_run = useCallback(\n (text: string): void => {\n add_blocks([{ role: \"user\", lines: [`you › ${text}`] }]);\n set_state((current) => ({ ...current, phase: \"thinking\", active_tool: undefined }));\n const controller = new AbortController();\n controller_ref.current = controller;\n const on_event = (event: AgentEvent): void => {\n set_state((current) => apply_event(current, event));\n set_blocks((current) => [...current, ...event_blocks(event)].slice(-HISTORY_CAP));\n };\n void run_agent_turn(agent, history_ref.current, text, on_event, finish_run, controller.signal)\n .catch((error: unknown) => {\n add_blocks([error_notice_block(run_error_text(error))]);\n set_state((current) => ({ ...current, phase: \"idle\" }));\n })\n .finally(() => {\n if (controller_ref.current === controller) {\n controller_ref.current = undefined;\n }\n });\n },\n [agent, add_blocks, finish_run, set_blocks, set_state],\n );\n\n useEffect(() => () => controller_ref.current?.abort(), []);\n\n return start_message_run;\n}\n\n/** Slash-command dispatch: pure client-side actions, never hits the agent. */\nfunction use_slash_commands(agent: Agent, add_blocks: AddBlocks, set_blocks: SetBlocks, total_tokens: number): (parsed: SlashInput) => void {\n const handle = useCallback(\n (parsed: SlashInput): void => {\n if (parsed.name === \"exit\" || parsed.name === \"quit\" || parsed.name === \"q\") {\n process.exit(0);\n return;\n }\n if (parsed.name === \"help\") {\n add_blocks([help_block()]);\n } else if (parsed.name === \"model\") {\n add_blocks([model_label_block(agent.config)]);\n } else if (parsed.name === \"usage\") {\n add_blocks([usage_notice_block(total_tokens)]);\n } else if (parsed.name === \"clear\") {\n set_blocks([]);\n } else if (parsed.name === \"sessions\") {\n void sessions_block(agent.config).then((block) => add_blocks([block]));\n } else {\n add_blocks([unknown_command_block(parsed.name)]);\n }\n },\n [agent, add_blocks, set_blocks, total_tokens],\n );\n return handle;\n}\n\ninterface TuiAppProps {\n readonly agent: Agent;\n}\n\nexport function TuiApp({ agent }: TuiAppProps): React.JSX.Element {\n const [blocks, set_blocks] = useState<readonly HistoryBlock[]>([]);\n const [state, set_state] = useState(INITIAL_UI_STATE);\n\n const add_blocks = useCallback<AddBlocks>((added: readonly HistoryBlock[]): void => {\n if (added.length === 0) {\n return;\n }\n set_blocks((current) => [...current, ...added].slice(-HISTORY_CAP));\n }, []);\n\n const start_message_run = use_agent_run(agent, add_blocks, set_state, set_blocks);\n const handle_slash = use_slash_commands(agent, add_blocks, set_blocks, state.usage.total_tokens);\n\n const submit = useCallback(\n (text: string): void => {\n const parsed = parse_command(text);\n if (parsed.kind === \"message\") {\n if (parsed.text.length > 0) {\n start_message_run(parsed.text);\n }\n return;\n }\n handle_slash(parsed);\n },\n [handle_slash, start_message_run],\n );\n\n const provider = agent.config.providers[0];\n return (\n <Box flexDirection=\"column\" minHeight={8}>\n <Text dimColor>{tui_banner_text(agent.config.agent_name, LICH_VERSION, provider?.model ?? \"unknown\", provider?.kind ?? \"unknown\")}</Text>\n <MessageView blocks={blocks} state={state} />\n <StatusBar state={state} model={provider?.model ?? \"unknown\"} />\n <CommandBar busy={state.phase !== \"idle\"} on_submit={submit} />\n </Box>\n );\n}","/**\n * Pure state logic for the ink TUI: UI-state transitions from agent events,\n * slash-command parsing, transcript block mapping, and display formatters.\n * No ink/react imports here — this module is unit-tested without a TTY.\n */\nimport type { AgentEvent } from \"../agent/events.js\";\nimport type { AgentRunResult } from \"../agent/agent.js\";\nimport type { AssistantMessage, Message, ToolCall, Usage } from \"../providers/types.js\";\nimport { safe_json_parse, truncate_text } from \"../util/json.js\";\n\nexport type UiPhase = \"idle\" | \"thinking\" | \"tool\";\n\nexport interface UiState {\n readonly phase: UiPhase;\n readonly turns_used: number;\n readonly usage: Usage;\n readonly session_path: string | undefined;\n readonly compress_count: number;\n readonly budget_exhausted: boolean;\n readonly last_error: string | undefined;\n readonly active_tool: ToolCall | undefined;\n}\n\nexport const INITIAL_UI_STATE: UiState = {\n phase: \"idle\",\n turns_used: 0,\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n session_path: undefined,\n compress_count: 0,\n budget_exhausted: false,\n last_error: undefined,\n active_tool: undefined,\n};\n\nexport const HISTORY_CAP = 50;\nconst TOOL_ARGS_PREVIEW_CHARS = 80;\nconst TOOL_OUTPUT_PREVIEW_CHARS = 120;\nconst ERROR_PREVIEW_CHARS = 300;\n\nfunction error_text(error: unknown): string {\n if (error instanceof Error) {\n return error.message;\n }\n return String(error);\n}\n\n/** Reducer over UiState; one pure mapping per AgentEvent variant. */\nexport function apply_event(state: UiState, event: AgentEvent): UiState {\n switch (event.type) {\n case \"llm_start\":\n return { ...state, phase: \"thinking\", active_tool: undefined };\n case \"llm_end\":\n return {\n ...state,\n usage: {\n prompt_tokens: state.usage.prompt_tokens + event.result.usage.prompt_tokens,\n completion_tokens: state.usage.completion_tokens + event.result.usage.completion_tokens,\n total_tokens: state.usage.total_tokens + event.result.usage.total_tokens,\n },\n };\n case \"tool_call_start\":\n return { ...state, phase: \"tool\", active_tool: event.call };\n case \"tool_call_end\":\n return {\n ...state,\n phase: \"thinking\",\n active_tool: undefined,\n last_error: event.result.ok === true ? state.last_error : (event.result.error ?? \"tool failed\"),\n };\n case \"turn_end\":\n return { ...state, turns_used: event.turn };\n case \"compress_start\":\n return { ...state, compress_count: state.compress_count + 1 };\n case \"budget_exhausted\":\n return { ...state, budget_exhausted: true };\n case \"error\":\n return { ...state, last_error: error_text(event.error) };\n default:\n return state;\n }\n}\n\n/** Fold an AgentRunResult back into UiState after the run promise resolves. */\nexport function apply_run_result(state: UiState, result: AgentRunResult): UiState {\n return {\n ...state,\n phase: \"idle\",\n turns_used: result.outcome.turns_used,\n session_path: result.session_path ?? state.session_path,\n last_error: result.outcome.stopped_reason === \"aborted\" ? \"run aborted\" : state.last_error,\n };\n}\n\nexport type ParsedInput = { kind: \"slash\"; name: string; args: string } | { kind: \"message\"; text: string };\n\n/** Split trimmed input into slash command vs plain message (empty input = message). */\nexport function parse_command(raw_input: string): ParsedInput {\n const text = raw_input.trim();\n if (text.startsWith(\"/\") === false) {\n return { kind: \"message\", text };\n }\n const body = text.slice(1);\n const space_index = body.indexOf(\" \");\n if (space_index === -1) {\n return { kind: \"slash\", name: body, args: \"\" };\n }\n return { kind: \"slash\", name: body.slice(0, space_index), args: body.slice(space_index + 1).trim() };\n}\n\n/** Dim header line: agent name, version, model, provider kind. */\nexport function tui_banner_text(agent_name: string, version: string, model: string, kind: string): string {\n return `${agent_name} v${version} — ${model} (${kind})`;\n}\n\n/** 1234567 -> \"1,234,567\" (US grouping, matching the status bar style). */\nexport function format_usage(total_tokens: number): string {\n return total_tokens.toLocaleString(\"en-US\");\n}\n\nexport type BlockRole = \"user\" | \"lich\" | \"tool\" | \"meta\" | \"error\";\n\nexport interface HistoryBlock {\n readonly role: BlockRole;\n readonly lines: readonly string[];\n}\n\nfunction assistant_tool_line(call: ToolCall): string {\n const args_json = JSON.stringify(call.args) ?? \"{}\";\n return ` \\u23bf ${truncate_text(args_json, TOOL_ARGS_PREVIEW_CHARS)}`;\n}\n\nfunction format_message_lines(message: Message): string[] {\n if (message.role === \"user\") {\n return [`you \\u203a ${message.content}`];\n }\n if (message.role === \"assistant\") {\n const lines = [`lich \\u203a ${message.content}`];\n for (const call of message.tool_calls ?? []) {\n lines.push(assistant_tool_line(call));\n }\n return lines;\n }\n if (message.role === \"tool\") {\n const flag = message.is_error === true ? \"error\" : \"ok\";\n return [` \\u23bf ${message.name}: ${flag} (${truncate_text(message.content, TOOL_OUTPUT_PREVIEW_CHARS)})`];\n }\n return [`\\u00b7 system: ${message.content}`];\n}\n\n/** Map one transcript Message to display lines with its role tag. */\nexport function format_message_block(message: Message): HistoryBlock {\n if (message.role === \"tool\") {\n const ok = message.is_error !== true;\n return { role: ok === true ? \"tool\" : \"error\", lines: format_message_lines(message) };\n }\n const roles: Record<Exclude<Message[\"role\"], \"tool\">, BlockRole> = {\n system: \"meta\",\n user: \"user\",\n assistant: \"lich\",\n };\n return { role: roles[message.role], lines: format_message_lines(message) };\n}\n\n/** Keep the newest `cap` non-system messages as renderable blocks. */\nexport function split_history_blocks(messages: readonly Message[], cap: number): HistoryBlock[] {\n const visible = messages.filter((message) => message.role !== \"system\");\n const start = Math.max(0, visible.length - cap);\n return visible.slice(start).map(format_message_block);\n}\n\nfunction assistant_result_block(message: AssistantMessage): HistoryBlock | undefined {\n if (message.content.length === 0) {\n return undefined;\n }\n return { role: \"lich\", lines: [`lich \\u203a ${message.content}`] };\n}\n\n/** Post-run meta blocks: compression notices, budget, errors, final answer. */\nexport function run_notice_blocks(result: AgentRunResult): HistoryBlock[] {\n const blocks: HistoryBlock[] = [];\n if (result.outcome.stopped_reason === \"budget\") {\n blocks.push({ role: \"error\", lines: [\"\\u00b7 budget exhausted (turn cap reached)\"] });\n }\n const final_block = result.outcome.final === undefined ? undefined : assistant_result_block(result.outcome.final);\n if (final_block !== undefined) {\n blocks.push(final_block);\n }\n return blocks;\n}\n\nfunction truncate_tool_preview(result_content: string, ok: boolean): string {\n return ` \\u23bf ${ok === true ? \"ok\" : \"error\"} (${truncate_text(result_content, TOOL_OUTPUT_PREVIEW_CHARS)})`;\n}\n\n/** One finalized live tool row; falls back to the transcript copy when absent. */\nexport function tool_result_block(call: ToolCall, ok: boolean, result_content: string): HistoryBlock {\n const args_json = JSON.stringify(call.args) ?? \"{}\";\n const lines = [\n `\\u23fa ${call.name}(${truncate_text(args_json, TOOL_ARGS_PREVIEW_CHARS)})`,\n truncate_tool_preview(result_content, ok),\n ];\n return { role: ok === true ? \"tool\" : \"error\", lines };\n}\n\nexport function parse_tool_message_content(content: string): { ok: boolean; output: string } {\n const parsed = safe_json_parse<{ ok?: unknown; output?: unknown }>(content);\n if (parsed !== undefined && typeof parsed.ok === \"boolean\" && typeof parsed.output === \"string\") {\n return { ok: parsed.ok, output: parsed.output };\n }\n return { ok: true, output: content };\n}\n\nexport function compress_notice_block(summary_chars: number): HistoryBlock {\n return { role: \"meta\", lines: [`\\u00b7 context compressed (summary ${summary_chars} chars)`] };\n}\n\nexport function error_notice_block(message: string): HistoryBlock {\n return { role: \"error\", lines: [`\\u00b7 error: ${truncate_text(message, ERROR_PREVIEW_CHARS)}`] };\n}\n\nexport function tool_args_preview(args: Record<string, unknown>): string {\n const args_json = JSON.stringify(args) ?? \"{}\";\n return truncate_text(args_json, TOOL_ARGS_PREVIEW_CHARS);\n}\n\nexport function help_block(): HistoryBlock {\n return { role: \"meta\", lines: [...HELP_LINES] };\n}\n\nexport function model_label_block(config: { providers: readonly { model: string; kind: string }[] }): HistoryBlock {\n const provider = config.providers[0];\n return {\n role: \"meta\",\n lines: [`\\u00b7 model: ${provider?.model ?? \"unknown\"} \\u00b7 provider: ${provider?.kind ?? \"unknown\"}`],\n };\n}\n\nexport function usage_notice_block(total_tokens: number): HistoryBlock {\n return { role: \"meta\", lines: [`\\u00b7 tokens used this session: ${format_usage(total_tokens)}`] };\n}\n\nexport function unknown_command_block(name: string): HistoryBlock {\n return { role: \"error\", lines: [`\\u00b7 unknown command: /${name} (try /help)`] };\n}\n\nexport interface SessionEntryInfo {\n readonly name: string;\n readonly size_bytes: number;\n readonly mtime_ms: number;\n}\n\n/** Newest-first session listing, capped at `cap` entries. */\nexport function session_list_block(entries: readonly SessionEntryInfo[], cap: number = 10): HistoryBlock {\n const sorted = [...entries].sort((a, b) => b.mtime_ms - a.mtime_ms).slice(0, cap);\n if (sorted.length === 0) {\n return { role: \"meta\", lines: [\"\\u00b7 no session files yet\"] };\n }\n const lines: string[] = [`\\u00b7 sessions (${sorted.length}):`];\n for (const entry of sorted) {\n lines.push(` ${entry.name} (${format_usage(entry.size_bytes)} bytes)`);\n }\n return { role: \"meta\", lines };\n}\n\nexport const SLASH_COMMAND_NAMES: readonly string[] = [\n \"exit\",\n \"quit\",\n \"q\",\n \"help\",\n \"model\",\n \"usage\",\n \"clear\",\n \"sessions\",\n];\n\nexport const HELP_LINES: readonly string[] = [\n \"commands: /help /model /usage /clear /sessions /exit (aliases: /quit /q)\",\n \"enter submits \\u00b7 backspace deletes \\u00b7 up/down recalls history \\u00b7 pasted newlines become spaces\",\n];","/**\n * Transcript rendering: maps HistoryBlock descriptors to ink elements with\n * role-based colors, and shows an animated braille spinner while thinking.\n * Blocks are pre-capped by the app, so a plain flex column is sufficient.\n */\nimport { useEffect, useState } from \"react\";\nimport { Box, Text } from \"ink\";\nimport { tool_args_preview, type HistoryBlock, type UiState } from \"./state.js\";\n\nconst SPINNER_FRAMES: readonly string[] = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\nconst SPINNER_INTERVAL_MS = 80;\n\n/** Braille spinner frames on an 80ms interval; clears on unmount. */\nfunction use_spinner(): string {\n const [frame, set_frame] = useState(SPINNER_FRAMES[0] ?? \"⠋\");\n useEffect(() => {\n const timer = setInterval(() => {\n const next = SPINNER_FRAMES[(SPINNER_FRAMES.indexOf(frame) + 1) % SPINNER_FRAMES.length];\n set_frame(next ?? \"⠋\");\n }, SPINNER_INTERVAL_MS);\n return () => {\n clearInterval(timer);\n };\n }, [frame]);\n return frame;\n}\n\nfunction ThinkingLine(): React.JSX.Element {\n const frame = use_spinner();\n return <Text dimColor>{`${frame} thinking…`}</Text>;\n}\n\nconst ROLE_COLORS: Record<HistoryBlock[\"role\"], string | undefined> = {\n user: \"white\",\n lich: \"green\",\n tool: \"cyan\",\n meta: undefined,\n error: \"red\",\n};\n\nfunction BlockLines({ block }: { block: HistoryBlock }): React.JSX.Element {\n const color = ROLE_COLORS[block.role];\n return (\n <>\n {block.lines.map((line, index) => (\n <Text key={index} color={color} dimColor={color === undefined}>{line}</Text>\n ))}\n </>\n );\n}\n\ninterface MessageViewProps {\n readonly blocks: readonly HistoryBlock[];\n readonly state: UiState;\n}\n\n/** Transcript column plus the live phase line (spinner / running tool row). */\nexport function MessageView({ blocks, state }: MessageViewProps): React.JSX.Element {\n return (\n <Box flexDirection=\"column\" flexGrow={1}>\n {blocks.map((block, index) => (\n <Box key={index} flexDirection=\"column\">\n <BlockLines block={block} />\n </Box>\n ))}\n {state.phase === \"thinking\" ? <ThinkingLine /> : null}\n {state.phase === \"tool\" && state.active_tool !== undefined ? (\n <Text color=\"cyan\">{`⏺ ${state.active_tool.name}(${tool_args_preview(state.active_tool.args)})`}</Text>\n ) : null}\n </Box>\n );\n}","/**\n * Bottom status line: model, turns, token totals, phase tag, compression\n * count, and the session path once the agent has persisted a transcript.\n */\nimport { Box, Text } from \"ink\";\nimport { format_usage, type UiState } from \"./state.js\";\n\nconst PHASE_LABELS: Record<UiState[\"phase\"], string> = {\n idle: \"idle\",\n thinking: \"thinking\",\n tool: \"tool\",\n};\n\ninterface StatusBarProps {\n readonly state: UiState;\n readonly model: string;\n}\n\nexport function StatusBar({ state, model }: StatusBarProps): React.JSX.Element {\n const phase = PHASE_LABELS[state.phase];\n return (\n <Box>\n <Text dimColor>\n {`model ${model} · turns ${state.turns_used} · tokens ${format_usage(state.usage.total_tokens)} · [${phase}]`}\n {state.compress_count > 0 ? ` · compressed ${state.compress_count}` : \"\"}\n {state.session_path !== undefined ? ` · ${state.session_path}` : \"\"}\n </Text>\n {state.budget_exhausted ? <Text color=\"red\"> · budget exhausted</Text> : null}\n </Box>\n );\n}","/**\n * Input row: printable characters accumulate in a buffer, Enter submits,\n * Backspace/Delete edits, Up/Down walk a 20-entry recall ring, and pasted\n * newlines collapse to spaces. Ctrl+C is left to ink's default handling.\n */\nimport { useState } from \"react\";\nimport { Box, Text, useInput } from \"ink\";\n\nconst INPUT_HISTORY_CAP = 20;\n\ninterface CommandBarProps {\n readonly busy: boolean;\n readonly on_submit: (text: string) => void;\n}\n\n/** Push onto a capped ring (newest first) without mutating the source. */\nfunction push_history(ring: readonly string[], entry: string): readonly string[] {\n return [entry, ...ring.filter((item) => item !== entry)].slice(0, INPUT_HISTORY_CAP);\n}\n\nexport function CommandBar({ busy, on_submit }: CommandBarProps): React.JSX.Element {\n const [buffer, set_buffer] = useState(\"\");\n const [recall_ring, set_recall_ring] = useState<readonly string[]>([]);\n const [recall_index, set_recall_index] = useState<number | undefined>(undefined);\n\n const submit_buffer = (): void => {\n const text = buffer.trim();\n set_buffer(\"\");\n set_recall_index(undefined);\n if (text.length > 0) {\n set_recall_ring((current) => push_history(current, text));\n on_submit(text);\n }\n };\n\n const walk_recall = (direction: 1 | -1): void => {\n if (recall_ring.length === 0) {\n return;\n }\n const current = recall_index ?? -direction;\n const next = Math.min(Math.max(current + direction, 0), recall_ring.length - 1);\n set_recall_index(next);\n set_buffer(recall_ring[next] ?? \"\");\n };\n\n useInput((input, key) => {\n if (key.return === true) {\n submit_buffer();\n return;\n }\n if (key.upArrow === true) {\n walk_recall(-1);\n return;\n }\n if (key.downArrow === true) {\n walk_recall(1);\n return;\n }\n if (key.backspace === true || key.delete === true) {\n set_buffer((current) => current.slice(0, -1));\n return;\n }\n if (key.ctrl === true || key.escape === true || key.tab === true || key.meta === true) {\n return;\n }\n if (input.length > 0) {\n set_buffer((current) => current + input.replaceAll(\"\\n\", \" \").replaceAll(\"\\r\", \" \"));\n }\n });\n\n return (\n <Box>\n <Text dimColor>{busy ? \" … \" : \"› \"}</Text>\n <Text>{buffer}</Text>\n <Text dimColor>▌</Text>\n </Box>\n );\n}"],"mappings":"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","names":["useEffect","useState","Box","Text","Box","Text","jsx","jsxs","Box","Text","useState","Box","Text","jsx","jsxs","jsx","jsxs","useEffect","useState","Box","Text","jsx"]}
@@ -28,7 +28,7 @@ flowchart TB
28
28
  CLIENTS["openai_compat / anthropic / ollama<br/>HTTP clients"]
29
29
  EXEC["ToolExecutor<br/>(src/tools/executor.ts)"]
30
30
  REG["ToolRegistry<br/>(src/tools/registry.ts)"]
31
- BUILTIN["12 builtin tools<br/>(src/tools/builtin/*)"]
31
+ BUILTIN["builtin tools<br/>(src/tools/builtin/*)"]
32
32
  COMP["ContextCompressor<br/>(src/context/compressor.ts)"]
33
33
  SESSION["SessionStore<br/>(src/session/store.ts)"]
34
34
 
@@ -59,18 +59,20 @@ structural interfaces ([`src/agent/loop.ts`](../../src/agent/loop.ts)):
59
59
 
60
60
  - `ChatFn` - `(messages, tools, options?) => Promise<ChatResult>` (declared in
61
61
  `src/context/compressor.ts`, since compression needs the same shape).
62
- - `ToolRunner` - `{ execute(name, args) => Promise<ToolResult> }`.
62
+ - `ToolRunner` - `{ execute(name, args, context?) => Promise<ToolResult> }`.
63
63
 
64
64
  `run_conversation` receives a `LoopDeps` object holding a `ChatFn`, a
65
- `ToolRunner`, a `definitions()` callback for tool schemas, and an optional
66
- emitter. The `Agent` class (`src/agent/agent.ts`) is the composition root: its
67
- `loop_deps()` method wires the real implementations -
65
+ `ToolRunner`, a `definitions()` callback for tool schemas, an optional
66
+ emitter, and an optional per-run `tool_context` threaded to every tool
67
+ execution. The `Agent` class (`src/agent/agent.ts`) is the composition root:
68
+ its `loop_deps()` method wires the real implementations -
68
69
 
69
70
  ```ts
70
71
  chat: (messages, tools, chat_options) => this.router.chat_with_failover(messages, tools, chat_options),
71
72
  tools: this.executor,
72
73
  definitions: () => this.registry.definitions(),
73
74
  emitter: this.events,
75
+ tool_context,
74
76
  ```
75
77
 
76
78
  (src/agent/agent.ts, `loop_deps()`)
@@ -166,7 +168,7 @@ Walkthrough of a single `Agent.run({ input })` call
166
168
  | `src/tools/guard.ts` | Path confinement, timeouts, clamping, arg coercion. |
167
169
  | `src/tools/registry.ts` | Name-keyed tool registry; duplicate rejection. |
168
170
  | `src/tools/executor.ts` | Never-throw execution with timeout and abort. |
169
- | `src/tools/builtin/*` | The 12 builtin tools (see [tools](./tools.md)). |
171
+ | `src/tools/builtin/*` | Builtin tools, including `run_tests` (see [tools](./tools.md)). |
170
172
  | `src/gateway/bus.ts` | Conversation-keyed runner over one shared `Agent`. |
171
173
  | `src/gateway/runner.ts` | Adapter construction, signal handling, process lifetime. |
172
174
  | `src/gateway/{telegram,discord,twitch,webhook}.ts` | Platform adapters. |
@@ -23,7 +23,7 @@ flowchart LR
23
23
  K -- yes --> M["LoadedPlugin collected"]
24
24
  M --> N["Agent constructor"]
25
25
  N --> O["registry merge:\nplugin tools appended\n(dup tool name → warn+skip)"]
26
- N --> P["hook concat:\nPluginHooks[] in config order"]
26
+ N --> P["hooked plugins kept\nwhole (per-plugin state channel)"]
27
27
  P --> Q["HookedToolRunner wraps\nToolExecutor when hooks exist"]
28
28
  E --> R["warn + continue"]
29
29
  H --> R
@@ -53,14 +53,66 @@ sequenceDiagram
53
53
  else no blocker
54
54
  H->>E: execute(name, args, context?)
55
55
  E-->>H: ToolResult
56
- H->>A: await hook({...info, result_summary}, ctx)
57
- note over A: summary = 300 chars of output/error
56
+ H->>A: await hook({...info, result_summary, ok, error?}, ctx)
57
+ note over A: summary = 300 chars of output/error; ok/error are structured
58
58
  H-->>L: ToolResult unchanged
59
59
  end
60
60
  ```
61
61
 
62
62
  Lifecycle fan-outs live on the same wrapper: `Agent.run` calls `call_run_start({input_chars})` before `run_conversation` and `call_run_end({stopped_reason, turns_used})` after it (including the abort/throw path, via `finally`). Both are best-effort: hook throws are logged at `warn` and the run proceeds.
63
63
 
64
+ ## Builtin gatekeeper
65
+
66
+ `Agent` constructs `gatekeeper_plugin` in code, before config plugins, and
67
+ pushes it as a synthetic `LoadedPlugin` through tool registration and the
68
+ hook runner. The config loader never sees it. Construction failure means
69
+ `git_commit` is not in the registry at all. "No gatekeeper → no `git_commit`"
70
+ is that trusted path: with the gatekeeper off, a config plugin may still name
71
+ a tool `git_commit` (the documented plugin-trust floor). While the gatekeeper
72
+ is registered, first-wins keeps its tool.
73
+
74
+ `LICH_ALLOW_SELF_COMMIT` is read from process env at construction. The spec
75
+ value is `1`. Unset or any other value is fail-closed.
76
+
77
+ Per-run state starts `tests_ok=false`, `dirty=true`, `commits=0` (swapped at
78
+ `call_run_start`). `after_tool_call` updates only on structured `ok`:
79
+
80
+ - `write_file` / `edit_file` success → `dirty=true`
81
+ - `run_tests` success → `tests_ok=true`, `dirty=false`
82
+ - `git_commit` success → `commits++`
83
+
84
+ `before_tool_call` vetoes `git_commit` unless
85
+ `allow_self_commit && tests_ok && !dirty && commits < 1`. The reason names
86
+ the failed condition: `self_commit_disabled`, `tests_not_ok`,
87
+ `worktree_dirty`, `commit_budget_exhausted`. The model sees
88
+ `blocked_by_plugin: <reason>`.
89
+
90
+ `terminal` is vetoed on a hardcoded denylist match; the reason is
91
+ `git_denylist: <pattern>`. Patterns: flag-tolerant `commit` and `push`
92
+ (`commit`, `-commit`, `--commit`, `push`, `-push`, `--push`) plus
93
+ any-occurrence `commit-tree` and `update-ref`. No `remote` pattern. One
94
+ commit per run, hardcoded.
95
+
96
+ `git_commit` args are `{message, paths}` with 1–50 paths relative to
97
+ `work_dir`. It rejects `""`, `.`, anything resolving to `work_dir`, and
98
+ secret-ish basenames (`.env`, `.env.local`, `*.pem`, `*.p12`, `id_rsa*`).
99
+ Unreachable `HEAD` is fail-closed. Recipe: scoped `git add -- <paths>`, then
100
+ `git commit --only` with explicit identity `-c` flags. `timeout_ms` is 60000.
101
+ It never pushes.
102
+
103
+ **Attestation:** clean state attests no `write_file`/`edit_file` since the
104
+ last green `run_tests`; it does NOT attest absence of terminal-mediated
105
+ writes — that sits with the documented terminal floor.
106
+
107
+ Floors, stated not closed:
108
+
109
+ - Terminal floor: raw `terminal` can run arbitrary git; the denylist is
110
+ best-effort. The boundary is human review of the local repo; push is
111
+ human-only.
112
+ - Plugin-trust floor: `.lich/config.json` `plugins` is persistent arbitrary
113
+ code at next process start. Review config diffs.
114
+ - One lich process per repo (the `run_tests` mutex is process-local).
115
+
64
116
  ## Design decisions
65
117
 
66
118
  - **Explicit entries, no directory scan.** v1 loads only the files you list in `config.plugins`. Directory scanning would make runs depend on whatever happens to sit in a folder — non-reproducible, and a footgun for tools that write into `.lich/`. Explicit entries make the agent's tool surface a function of the config alone.
@@ -74,7 +126,8 @@ Lifecycle fan-outs live on the same wrapper: `Agent.run` calls `call_run_start({
74
126
  | --- | --- | --- |
75
127
  | `Plugin` | type | `{name, version?, tools?, hooks?}` — what a plugin module exports. |
76
128
  | `PluginHooks` | type | The four optional lifecycle hooks with their signatures. |
77
- | `HookContext` | type | `{work_dir}` passed to every hook. |
129
+ | `HookContext` | type | `{work_dir, state?}` passed to every hook; `state` is the invoking plugin's own per-run bag. |
130
+ | `AfterToolCallInfo` | type | Tool name/args plus the 300-char `result_summary` and structured `ok`/`error` fields. |
78
131
  | `LoadedPlugin` | type | `{plugin, entry}` — a loaded plugin and its source path. |
79
132
  | `load_plugins` | function | `(entries, base_dir) => {plugins, errors}` — dynamic import + shape validation. |
80
133
  | `plugin_errors_summary` | function | Joins error entries into one warn-able string. |
@@ -118,9 +118,10 @@ args, context?)`:
118
118
  defaults (falling back to `process.cwd()`) plus the configured `env`.
119
119
  3. **Cancelled fast path**: if the context signal is already aborted, return
120
120
  `{ ok: false, output: "", error: "cancelled" }` without running the tool.
121
- 4. **Signal merge + 30 s timeout**: a fresh `AbortController` is aborted by
121
+ 4. **Signal merge + timeout**: a fresh `AbortController` is aborted by
122
122
  the external signal (an `abort` listener), by the `with_timeout` deadline
123
- (`DEFAULT_TOOL_TIMEOUT_MS = 30000`), and the merged signal is what the tool
123
+ (the tool's own `timeout_ms` when declared, else
124
+ `DEFAULT_TOOL_TIMEOUT_MS = 30000`), and the merged signal is what the tool
124
125
  receives. The external listener is removed in a `finally`.
125
126
  5. **Output clamping**: successful results pass through `clamp_result`
126
127
  (`clamp_output`, 20 000 chars).
@@ -137,8 +138,9 @@ form back with `parse_tool_message_content` (src/tui/state.ts).
137
138
 
138
139
  ## Builtin catalog
139
140
 
140
- Twelve tools, registered by `register_builtin_tools`
141
- ([`src/tools/builtin/index.ts`](../../src/tools/builtin/index.ts)):
141
+ Registered by `register_builtin_tools`
142
+ ([`src/tools/builtin/index.ts`](../../src/tools/builtin/index.ts)).
143
+ Docs tools join the list only when a docs root resolves.
142
144
 
143
145
  | Tool | Key args | Implementation insight |
144
146
  | --- | --- | --- |
@@ -154,6 +156,7 @@ Twelve tools, registered by `register_builtin_tools`
154
156
  | `process_list` | `filter?`, `max_results?` | Reads `/proc` synchronously: numeric dirs are pids, `cmdline` is NUL-separated; missing entries (process died mid-scan) read as empty. |
155
157
  | `disk_usage` | `path?`, `max_entries?` | One `du -sb` subprocess per depth-1 entry with a 10 s timeout; sorted desc with a `TOTAL` row; `du` missing yields `du_unavailable`. |
156
158
  | `env_get` | `keys?`, `prefix?`, `reveal?` | Values are hidden unless `reveal`; names matching `/(secret\|token\|password\|key\|credential\|auth)/i` are **always** masked as `<redacted: N chars>`. |
159
+ | `run_tests` | `filter?` | Runs `LICH_TEST_COMMAND` (default `node node_modules/vitest/vitest.mjs run`) in `work_dir` via `bash -lc`. `timeout_ms` is 600000. A module mutex makes a concurrent call return `{ok:false, error:"run_tests_busy"}`. `ok` is the structured pass/fail the gatekeeper reads; output is clamped to 2000 chars. One lich process per repo — a second process is fail-closed busy or failed. |
157
160
 
158
161
  The three HTTP tools (`fetch_url`, `web_search`, `http_request`) share
159
162
  helpers from `fetch_url.ts`: `valid_http_url` (URL parse + protocol
@@ -161,6 +164,15 @@ allowlist), `compose_abort_signal` (per-call `AbortSignal.timeout` merged
161
164
  with the executor's cancellation via `AbortSignal.any`), and `clamp_int_arg`
162
165
  (floored, bounded to `[1, max]`).
163
166
 
167
+ ## Docs search and skills
168
+
169
+ `docs_search` scores sections under the resolved docs root (memoized) and, when
170
+ `<work_dir>/.lich/skills/` exists, also walks that directory. The skills
171
+ candidate is existence-only: it does not need `index.md`. The walk is fresh
172
+ on every call — user-writable skill files are not memoized into the package
173
+ docs cache. Skills are reference data, written with `write_file`, not
174
+ instructions. See the [plugins guide](../user-guide/plugins.md#skills-and-memory).
175
+
164
176
  ## Registry
165
177
 
166
178
  [`ToolRegistry`](../../src/tools/registry.ts) is a name-keyed `Map`:
@@ -42,6 +42,8 @@ lich config > .lich/config.json
42
42
 
43
43
  `lich config` honors `LICH_PROVIDER_KIND` and `LICH_MODEL` when you have them set, and otherwise prints an ollama-oriented template. The file is picked up automatically from `.lich/config.json` in the working directory (or `~/.config/lich/config.json` as a fallback) — after this, plain `lich "task"` needs no env vars.
44
44
 
45
+ `lich init` writes that same starter file for you (it creates `.lich/` and never overwrites an existing `.lich/config.json`). Bare `lich` on a TTY, with no config in that search chain and no `LICH_MODEL`, runs a setup wizard and writes `.lich/config.json` once before opening the TUI. `.lich/` is gitignored.
46
+
45
47
  ### Path C: an explicit config file
46
48
 
47
49
  ```sh
@@ -70,7 +72,8 @@ Exit code `0` means the model produced a final answer; `1` means the turn budget
70
72
  ## Your first TUI session
71
73
 
72
74
  ```sh
73
- lich tui
75
+ lich # TUI; first run on a TTY opens the setup wizard
76
+ lich tui # same TUI, no wizard
74
77
  ```
75
78
 
76
79
  Type a message and press Enter. The transcript shows your line, live tool-call rows while the agent works, and the reply; the status bar at the bottom tracks turns, tokens, and the session file path. Slash commands: `/help`, `/model`, `/usage`, `/clear`, `/sessions`, `/exit`. Details in [the TUI guide](user-guide/tui.md).
@@ -117,7 +120,7 @@ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content)"' .lich
117
120
 
118
121
  | Symptom | Cause and fix |
119
122
  | --- | --- |
120
- | `no model configured: set LICH_MODEL, pass --model, or create .lich/config.json` | No provider was resolvable. Set `LICH_MODEL`, pass `--model`, or save a config file (`lich config`). |
123
+ | `no model configured: set LICH_MODEL, pass --model, or create .lich/config.json` | No provider was resolvable. Set `LICH_MODEL`, pass `--model`, or save a config file (`lich init` or `lich config`). |
121
124
  | `lich: config not found: <path>` | `--config` was given a path that does not exist. Check the path or drop the flag to use discovery. |
122
125
  | Provider error `kind=auth`, http 401/403 | The api key is missing or wrong. Verify the env var named by `LICH_API_KEY_ENV` (default `OPENAI_API_KEY`/`ANTHROPIC_API_KEY`) is exported in the same shell. |
123
126
  | `fetch failed` / connection refused | The endpoint is unreachable. For ollama, check `ollama serve` is running on `http://localhost:11434`; for remote APIs, check `LICH_BASE_URL`. |
@@ -126,13 +129,21 @@ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content)"' .lich
126
129
 
127
130
  ## Updating
128
131
 
129
- Lich updates in place with npm:
132
+ Check the npm registry and install a newer release with:
130
133
 
131
134
  ```sh
132
- npm install -g @moikapy/lich@latest
135
+ lich update
133
136
  lich --version # -> the version you just installed
134
137
  ```
135
138
 
139
+ `lich update` compares the installed version to `npm view @moikapy/lich version`. When the registry copy is newer, it runs the equivalent command:
140
+
141
+ ```sh
142
+ npm install -g @moikapy/lich@latest
143
+ ```
144
+
145
+ Exit any running `lich tui` or `lich gateway` first — npm cannot replace the package while those processes are running. A git clone updates with `git pull` instead; `npx` cannot persist an update.
146
+
136
147
  Updates never touch your data: the per-project `.lich/` directory holds your config and session transcripts, installers neither read nor migrate it, and it is gitignored by design so a checkout never collides with it. For what changed between versions, see the [changelog](https://github.com/moikapy/lich/blob/main/CHANGELOG.md).
137
148
 
138
149
  ## Development install (from source)
package/docs/index.md CHANGED
@@ -8,14 +8,14 @@ outline: [2, 3]
8
8
 
9
9
  Lich is a TypeScript AI agent harness: a library and a CLI that run a chat model inside a Think-Act-Observe loop. A chat wrapper forwards one prompt and prints one completion. A harness keeps going: the model plans (think), calls tools such as `read_file` or `terminal` (act), reads the tool results (observe), and repeats until it can produce a final answer. Lich wraps that loop with the machinery real deployments need: provider failover with bounded retries, path confinement and output clamps on every tool, context compression when the transcript grows past a token budget, and append-only JSONL session transcripts.
10
10
 
11
- One package, four ways to drive the same agent: a one-shot CLI, an interactive chat REPL, an ink-based terminal UI, and a long-running messaging gateway that bridges Telegram, Discord, Twitch, and a zero-config HTTP webhook. All four share the same twelve builtin tools, the same provider configuration, and the same session store.
11
+ One package, four ways to drive the same agent: a one-shot CLI, an interactive chat REPL, an ink-based terminal UI, and a long-running messaging gateway that bridges Telegram, Discord, Twitch, and a zero-config HTTP webhook. All four share the same builtin tools, the same provider configuration, and the same session store.
12
12
 
13
13
  ## Feature overview
14
14
 
15
15
  | Capability | What it gives you |
16
16
  | --- | --- |
17
17
  | Providers | `openai_compat`, `anthropic`, and `ollama` with automatic failover between configured providers; 429/5xx and network errors retry with backoff before failing over. |
18
- | Tools | Twelve builtins (file read/write/edit, directory listing, shell, grep, HTTP fetch/request, web search, process list, disk usage, env inspection), all confined to the working directory. |
18
+ | Tools | Builtins (file read/write/edit, directory listing, shell, grep, HTTP fetch/request, web search, process list, disk usage, env inspection, `run_tests`), all confined to the working directory. `git_commit` is the gatekeeper's tool, not a config plugin. |
19
19
  | Context compression | Transcript summarized in place when estimated tokens cross `compress_threshold` of `context_budget_tokens`; the 8 most recent turns always stay verbatim. |
20
20
  | Sessions | Every run persists a `.jsonl` transcript under `.lich/sessions/`, labeled by origin (`tui`, `gw:<platform>:<chat>`). |
21
21
  | CLI | One-shot tasks, chat REPL, TUI, gateway, and a `config` template command, all with flag/env/config-file configuration. |
@@ -33,7 +33,8 @@ One package, four ways to drive the same agent: a one-shot CLI, an interactive c
33
33
  | [TUI guide](user-guide/tui.md) | Run the terminal UI and use slash commands and the status bar. |
34
34
  | [Gateway guide](user-guide/gateway.md) | Wire Telegram, Discord, Twitch, and the HTTP webhook to one agent. |
35
35
  | [Library guide](user-guide/library.md) | Embed the agent in TypeScript with events and multi-turn history. |
36
- | [Plugins guide](user-guide/plugins.md) | Add your own tools and lifecycle hooks to the agent. |
36
+ | [Plugins guide](user-guide/plugins.md) | Add your own tools and lifecycle hooks, and run the self-improvement loop. |
37
+ | [Godot guide](user-guide/godot.md) | Run lich beside a Godot game and drain `.lich/game/` orders each tick. |
37
38
  | [Architecture overview](architecture/overview.md) | Understand how the harness works inside. |
38
39
 
39
40
  ## How it works