@gaunt-sloth/core 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (158) hide show
  1. package/.gsloth.code.md +10 -0
  2. package/README.md +6 -6
  3. package/dist/config/defaults.d.ts +85 -0
  4. package/dist/config/defaults.js +103 -0
  5. package/dist/config/defaults.js.map +1 -0
  6. package/dist/config/jsonc.d.ts +12 -0
  7. package/dist/config/jsonc.js +41 -0
  8. package/dist/config/jsonc.js.map +1 -0
  9. package/dist/config/loader.d.ts +169 -0
  10. package/dist/config/loader.js +872 -0
  11. package/dist/config/loader.js.map +1 -0
  12. package/dist/config/schema.d.ts +673 -0
  13. package/dist/config/schema.js +524 -0
  14. package/dist/config/schema.js.map +1 -0
  15. package/dist/config/shell-policy.d.ts +331 -0
  16. package/dist/config/shell-policy.js +238 -0
  17. package/dist/config/shell-policy.js.map +1 -0
  18. package/dist/config/types.d.ts +596 -0
  19. package/dist/config/types.js +13 -0
  20. package/dist/config/types.js.map +1 -0
  21. package/dist/config.d.ts +18 -647
  22. package/dist/config.js +15 -516
  23. package/dist/config.js.map +1 -1
  24. package/dist/constants.d.ts +42 -0
  25. package/dist/constants.js +42 -0
  26. package/dist/constants.js.map +1 -1
  27. package/dist/core/GthAbstractAgent.d.ts +85 -2
  28. package/dist/core/GthAbstractAgent.js +398 -28
  29. package/dist/core/GthAbstractAgent.js.map +1 -1
  30. package/dist/core/GthAgentRunner.d.ts +158 -1
  31. package/dist/core/GthAgentRunner.js +367 -4
  32. package/dist/core/GthAgentRunner.js.map +1 -1
  33. package/dist/core/GthLangChainAgent.js +193 -16
  34. package/dist/core/GthLangChainAgent.js.map +1 -1
  35. package/dist/core/debugCapture.d.ts +59 -0
  36. package/dist/core/debugCapture.js +101 -0
  37. package/dist/core/debugCapture.js.map +1 -0
  38. package/dist/core/gthLeanAgentFactory.d.ts +9 -0
  39. package/dist/core/gthLeanAgentFactory.js +10 -0
  40. package/dist/core/gthLeanAgentFactory.js.map +1 -0
  41. package/dist/core/plainToolIndication.d.ts +15 -0
  42. package/dist/core/plainToolIndication.js +148 -0
  43. package/dist/core/plainToolIndication.js.map +1 -0
  44. package/dist/core/runStats.d.ts +41 -0
  45. package/dist/core/runStats.js +73 -0
  46. package/dist/core/runStats.js.map +1 -0
  47. package/dist/core/shell/ShellCommandFailedError.d.ts +54 -0
  48. package/dist/core/shell/ShellCommandFailedError.js +68 -0
  49. package/dist/core/shell/ShellCommandFailedError.js.map +1 -0
  50. package/dist/core/shell/allowlist.d.ts +75 -0
  51. package/dist/core/shell/allowlist.js +187 -0
  52. package/dist/core/shell/allowlist.js.map +1 -0
  53. package/dist/core/shell/arity.d.ts +75 -0
  54. package/dist/core/shell/arity.js +313 -0
  55. package/dist/core/shell/arity.js.map +1 -0
  56. package/dist/core/shell/judge.d.ts +161 -0
  57. package/dist/core/shell/judge.js +261 -0
  58. package/dist/core/shell/judge.js.map +1 -0
  59. package/dist/core/shell/normalize.d.ts +27 -0
  60. package/dist/core/shell/normalize.js +53 -0
  61. package/dist/core/shell/normalize.js.map +1 -0
  62. package/dist/core/toolDisplay.d.ts +101 -0
  63. package/dist/core/toolDisplay.js +374 -0
  64. package/dist/core/toolDisplay.js.map +1 -0
  65. package/dist/core/toolOutputChannel.d.ts +68 -0
  66. package/dist/core/toolOutputChannel.js +112 -0
  67. package/dist/core/toolOutputChannel.js.map +1 -0
  68. package/dist/core/types.d.ts +173 -0
  69. package/dist/core/types.js.map +1 -1
  70. package/dist/history/historyFormat.d.ts +28 -0
  71. package/dist/history/historyFormat.js +127 -0
  72. package/dist/history/historyFormat.js.map +1 -0
  73. package/dist/history/historyStore.d.ts +198 -0
  74. package/dist/history/historyStore.js +482 -0
  75. package/dist/history/historyStore.js.map +1 -0
  76. package/dist/history/recordSession.d.ts +37 -0
  77. package/dist/history/recordSession.js +56 -0
  78. package/dist/history/recordSession.js.map +1 -0
  79. package/dist/index.d.ts +4 -0
  80. package/dist/index.js +4 -0
  81. package/dist/index.js.map +1 -1
  82. package/dist/providers/anthropic.d.ts +1 -1
  83. package/dist/providers/anthropic.js +5 -10
  84. package/dist/providers/anthropic.js.map +1 -1
  85. package/dist/providers/deepseek.d.ts +1 -1
  86. package/dist/providers/deepseek.js +5 -10
  87. package/dist/providers/deepseek.js.map +1 -1
  88. package/dist/providers/geminiSchemaSanitizer.d.ts +52 -0
  89. package/dist/providers/geminiSchemaSanitizer.js +201 -0
  90. package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
  91. package/dist/providers/google-genai.d.ts +1 -1
  92. package/dist/providers/google-genai.js +9 -11
  93. package/dist/providers/google-genai.js.map +1 -1
  94. package/dist/providers/groq.d.ts +1 -1
  95. package/dist/providers/groq.js +5 -10
  96. package/dist/providers/groq.js.map +1 -1
  97. package/dist/providers/huggingface.d.ts +25 -0
  98. package/dist/providers/huggingface.js +69 -0
  99. package/dist/providers/huggingface.js.map +1 -0
  100. package/dist/providers/modelCatalog.d.ts +109 -0
  101. package/dist/providers/modelCatalog.js +245 -0
  102. package/dist/providers/modelCatalog.js.map +1 -0
  103. package/dist/providers/modelDiscovery.d.ts +111 -3
  104. package/dist/providers/modelDiscovery.js +183 -27
  105. package/dist/providers/modelDiscovery.js.map +1 -1
  106. package/dist/providers/ollama.d.ts +19 -5
  107. package/dist/providers/ollama.js +60 -52
  108. package/dist/providers/ollama.js.map +1 -1
  109. package/dist/providers/openai.d.ts +1 -1
  110. package/dist/providers/openai.js +5 -10
  111. package/dist/providers/openai.js.map +1 -1
  112. package/dist/providers/openrouter.d.ts +1 -1
  113. package/dist/providers/openrouter.js +12 -12
  114. package/dist/providers/openrouter.js.map +1 -1
  115. package/dist/providers/vertexai.d.ts +1 -1
  116. package/dist/providers/vertexai.js +9 -11
  117. package/dist/providers/vertexai.js.map +1 -1
  118. package/dist/providers/xai.d.ts +1 -1
  119. package/dist/providers/xai.js +5 -10
  120. package/dist/providers/xai.js.map +1 -1
  121. package/dist/runtime/askStructured.d.ts +62 -0
  122. package/dist/runtime/askStructured.js +76 -0
  123. package/dist/runtime/askStructured.js.map +1 -0
  124. package/dist/runtime/conversation.d.ts +59 -0
  125. package/dist/runtime/conversation.js +137 -0
  126. package/dist/runtime/conversation.js.map +1 -0
  127. package/dist/runtime/singleShot.d.ts +24 -5
  128. package/dist/runtime/singleShot.js +56 -9
  129. package/dist/runtime/singleShot.js.map +1 -1
  130. package/dist/utils/aiignoreUtils.js.map +1 -1
  131. package/dist/utils/binaryOutputUtils.js.map +1 -1
  132. package/dist/utils/consoleUtils.d.ts +22 -0
  133. package/dist/utils/consoleUtils.js +45 -0
  134. package/dist/utils/consoleUtils.js.map +1 -1
  135. package/dist/utils/debugDump.d.ts +57 -0
  136. package/dist/utils/debugDump.js +236 -0
  137. package/dist/utils/debugDump.js.map +1 -0
  138. package/dist/utils/debugUtils.d.ts +13 -4
  139. package/dist/utils/debugUtils.js +36 -13
  140. package/dist/utils/debugUtils.js.map +1 -1
  141. package/dist/utils/fileUtils.d.ts +24 -2
  142. package/dist/utils/fileUtils.js +54 -12
  143. package/dist/utils/fileUtils.js.map +1 -1
  144. package/dist/utils/llmUtils.js.map +1 -1
  145. package/dist/utils/redactSecrets.d.ts +63 -0
  146. package/dist/utils/redactSecrets.js +238 -0
  147. package/dist/utils/redactSecrets.js.map +1 -0
  148. package/dist/utils/systemPromptNotes.d.ts +181 -0
  149. package/dist/utils/systemPromptNotes.js +298 -0
  150. package/dist/utils/systemPromptNotes.js.map +1 -0
  151. package/dist/utils/systemUtils.d.ts +32 -1
  152. package/dist/utils/systemUtils.js +50 -3
  153. package/dist/utils/systemUtils.js.map +1 -1
  154. package/dist/utils/toolMatching.d.ts +30 -0
  155. package/dist/utils/toolMatching.js +44 -0
  156. package/dist/utils/toolMatching.js.map +1 -0
  157. package/package.json +17 -8
  158. package/schema/gsloth-config.schema.json +2111 -0
@@ -1,6 +1,6 @@
1
1
  import { GthConfig } from '#src/config.js';
2
2
  import { BaseCheckpointSaver } from '@langchain/langgraph';
3
- import { AgentResolvers, AgentStreamEvent, GthAgentFactory, GthAgentInterface, GthCommand, Message, StatusUpdateCallback } from '#src/core/types.js';
3
+ import { AgentResolvers, AgentStreamEvent, GthAgentFactory, GthAgentInterface, GthCommand, GthRunStats, Message, StatusUpdateCallback, ToolApprovalCallback } from '#src/core/types.js';
4
4
  /**
5
5
  * Agent simplifies interaction with LLM and reduces it to calling a few methods
6
6
  * {@link GthAgentRunner#init} and {@link GthAgentRunner#processMessages}.
@@ -12,6 +12,47 @@ export declare class GthAgentRunner {
12
12
  private config;
13
13
  private runConfig;
14
14
  private agentFactory;
15
+ /**
16
+ * Consumer hook invoked when a run suspends on a tool-approval interrupt (e.g. the opt-in
17
+ * `run_shell_command` confirmation). Set via {@link setToolApprovalCallback}; when unset the
18
+ * runner REJECTS pending tool calls rather than hanging or auto-approving — the safe default
19
+ * for non-interactive entrypoints (a scripted `exec` run with no TTY to prompt on).
20
+ */
21
+ private toolApprovalCallback;
22
+ /** The command the runner was initialized for; selects which `devTools` config applies. */
23
+ private command;
24
+ /**
25
+ * GS2-16 — snapshot of the last turn's analytics (token usage + invoked tools), captured from
26
+ * the agent at {@link cleanup} time. Needed because {@link runSingleShot} reads stats AFTER it
27
+ * has already called `cleanup()` (which nulls the agent); interactive callers read live via
28
+ * {@link getRunStats} before cleanup. Defaults to an empty tally.
29
+ */
30
+ private lastRunStats;
31
+ /**
32
+ * EXT-12 — runtime, session-scoped auto-approve flag driven by the `/auto-approve` (a.k.a.
33
+ * `/yolo`) slash command. Because the shell tool stays gated (in `interruptOn`) in the
34
+ * interactive `code` mode, this flag is consulted at the TOP of {@link decideToolApproval}:
35
+ * when ON, a gated `run_shell_command` is auto-approved WITHOUT prompting for the rest of this
36
+ * runner's life. Never persisted, but INITIALIZED at {@link init} from the static
37
+ * `run_shell_command.yolo` config knob — so a config that pre-enables auto-approval still keeps the
38
+ * tool gated and therefore toggleable (`/auto-approve off` restores the per-command prompt).
39
+ * It does NOT disable the hardline floor — catastrophic commands are still refused at exec time
40
+ * in `GthDevToolkit.executeCommand`.
41
+ */
42
+ private sessionYolo;
43
+ /**
44
+ * EXT-9 Tier-2 session allow-list — approved command prefixes that auto-approve for the
45
+ * life of THIS runner instance. Instance-scoped (not module-global) so concurrent
46
+ * sessions (ACP / AG-UI multi-session) cannot stomp each other's approvals.
47
+ */
48
+ private readonly sessionAllowlist;
49
+ /**
50
+ * EXT-9 Tier-2 persisted (`always`) allow-list, loaded lazily on first use from
51
+ * `.gsloth/.gsloth-settings/shell-allowlist.json`. Null until the shell tool is gated
52
+ * and the allow-list is enabled; null also when persistence is disabled by config.
53
+ */
54
+ private persistedAllowlist;
55
+ private persistedAllowlistLoaded;
15
56
  /**
16
57
  * @param agentFactory Produces the {@link GthAgentInterface} the runner drives.
17
58
  * Defaults to the lean {@link GthLangChainAgent} (core). `@gaunt-sloth/agent`
@@ -19,6 +60,28 @@ export declare class GthAgentRunner {
19
60
  * `createDeepAgent` graph without core depending on deepagents.
20
61
  */
21
62
  constructor(statusUpdate: StatusUpdateCallback, resolvers?: AgentResolvers, agentFactory?: GthAgentFactory);
63
+ /**
64
+ * Register the tool-approval handler the runner calls when a run suspends on a tool-approval
65
+ * interrupt (the interactive readline session wires a y/n prompt here). Pass `null` to clear.
66
+ * Without a handler the runner rejects pending tool calls (see {@link toolApprovalCallback}).
67
+ */
68
+ setToolApprovalCallback(callback: ToolApprovalCallback | null): void;
69
+ /**
70
+ * EXT-12 — flip the runtime, session-scoped auto-approve flag (the `/auto-approve` /
71
+ * `/yolo` slash command with no argument). When ON, gated `run_shell_command` calls
72
+ * auto-approve without prompting for the rest of this session; the hardline floor still applies
73
+ * at exec time. Returns the NEW state so the caller can render a notice. Session-scoped only —
74
+ * nothing is written to config.
75
+ */
76
+ toggleSessionYolo(): boolean;
77
+ /**
78
+ * EXT-12 — set the session-scoped auto-approve flag explicitly (the `/auto-approve on|off`
79
+ * slash command). Idempotent; returns the NEW state so the caller can render a notice.
80
+ * Session-scoped only — nothing is written to config.
81
+ */
82
+ setSessionYolo(on: boolean): boolean;
83
+ /** EXT-12 — current state of the runtime session-scoped auto-approve flag (see {@link toggleSessionYolo}). */
84
+ isSessionYolo(): boolean;
22
85
  /**
23
86
  * Init is split into a separate method. This may create a number of connections,
24
87
  * and we'd better have an instance by that moment, for the case things will go wrong,
@@ -29,6 +92,59 @@ export declare class GthAgentRunner {
29
92
  * processMessages deals with both streaming and non-streaming approaches.
30
93
  */
31
94
  processMessages(messages: Message[]): Promise<string>;
95
+ /**
96
+ * Accumulate a text stream into a single string. Extracted so {@link processMessages} and
97
+ * the interrupt-resume loop ({@link resolveToolInterrupts}) drain streams identically.
98
+ */
99
+ private drainTextStream;
100
+ /**
101
+ * After a streamed run ends, resolve any tool-approval interrupts it suspended on. For each
102
+ * pending tool call the {@link toolApprovalCallback} is consulted (defaulting to REJECT when
103
+ * no handler is wired, so a non-interactive run never hangs or auto-approves); the collected
104
+ * decisions are then sent back via the agent's `streamResume` as a LangChain HITL resume
105
+ * (`{ decisions }`). Because a resumed run can suspend again on the next gated tool call, this
106
+ * loops until the graph completes with no pending interrupts. Returns the concatenated text
107
+ * streamed across all resume turns (empty when nothing was resumed).
108
+ *
109
+ * No-ops (returns '') when the agent does not support interrupts (`getPendingToolInterrupts`/
110
+ * `streamResume` absent), so the lean agent and non-HITL configs are unaffected.
111
+ */
112
+ private resolveToolInterrupts;
113
+ /**
114
+ * Decide a single pending tool call (EXT-9 Tier-2). For the opt-in `run_shell_command`,
115
+ * consult the scoped allow-list FIRST: if the command's classified prefix is already
116
+ * approved (session or persisted `always`) and survives the safe-bin anti-widening
117
+ * re-validation, auto-approve SILENTLY (no human prompt). Otherwise fall through to the
118
+ * human callback; when the human grants `session`/`always` scope, record the command's
119
+ * classified prefix into the matching store so future flag-variants stop re-prompting.
120
+ *
121
+ * When no human callback is wired (non-TTY exec run) and nothing is allow-listed, reject —
122
+ * never auto-approve. Non-shell tools (or any tool when the allow-list is disabled) skip the
123
+ * allow-list and go straight to the human callback / default-reject, preserving prior behaviour.
124
+ *
125
+ * Hardline catastrophic commands remain refused at exec time regardless of any approval here
126
+ * (defense in depth in `GthDevToolkit.executeCommand`), so an allow-listed `rm -rf /` still
127
+ * cannot run.
128
+ */
129
+ private decideToolApproval;
130
+ /** Whether the EXT-10 LLM-as-judge safety gate is enabled for the active command's config. */
131
+ private isShellJudgeOn;
132
+ /** Whether the EXT-9 Tier-2 allow-list is enabled for the active command's devTools config. */
133
+ private isShellAllowlistOn;
134
+ /**
135
+ * Lazily load (once per instance) the persisted `always` allow-list, unless persistence is
136
+ * disabled by config. Returns null when persistence is off so `always` grants behave as
137
+ * `session` (in-memory only).
138
+ */
139
+ private getPersistedAllowlist;
140
+ /** Check the command against the session + persisted stores (with anti-widening re-validation). */
141
+ private isApprovedByAllowlist;
142
+ /**
143
+ * Record a human-granted approval at the given scope. `once` persists nothing. `session`
144
+ * adds the classified prefix to the in-memory store. `always` additionally persists it (or
145
+ * falls back to session-only when persistence is disabled).
146
+ */
147
+ private recordApproval;
32
148
  /**
33
149
  * Event-stream counterpart to {@link processMessages}: drives the agent's typed
34
150
  * {@link AgentStreamEvent} path using the runner's own thread-bound `runConfig`, so a
@@ -39,9 +155,50 @@ export declare class GthAgentRunner {
39
155
  * underlying `streamWithEvents` ends cleanly on abort or `interrupt()`. The string
40
156
  * path's empty-stream retry/`invoke` fallback is intentionally NOT duplicated here — the
41
157
  * TUI renders the live event stream directly; revisit if empty-stream retries are needed.
158
+ *
159
+ * Tool-approval round-trip (EXT-11): after the stream ends, a gated `run_shell_command`
160
+ * leaves the graph suspended on a `humanInTheLoopMiddleware` interrupt rather than
161
+ * completing. This is the event-stream counterpart to the readline path's
162
+ * {@link resolveToolInterrupts}: it drains any pending interrupts through
163
+ * {@link decideToolApproval} (allow-list → judge → bridged human prompt), resumes via
164
+ * `streamWithEventsResume({ decisions })`, and loops until the graph completes with no
165
+ * pending interrupts — so the executed command's output renders into the TUI. Without
166
+ * this the TUI silently finalized an empty turn (approval gate was dead code on the
167
+ * event-stream path).
42
168
  */
43
169
  processMessagesWithEvents(messages: Message[], signal?: AbortSignal): AsyncGenerator<AgentStreamEvent>;
170
+ /**
171
+ * Event-stream counterpart to {@link resolveToolInterrupts}: after a streamed run ends,
172
+ * resolve any tool-approval interrupts it suspended on, yielding the resumed run's typed
173
+ * {@link AgentStreamEvent}s so the renderer (the Ink TUI) shows the executed command's
174
+ * output. Each pending tool call is consulted via {@link decideToolApproval} — the SAME
175
+ * three-layer gate the readline path uses (allow-list auto-approve → EXT-10 judge →
176
+ * bridged human callback, defaulting to REJECT when no handler is wired) — and the
177
+ * collected decisions are sent back via `streamWithEventsResume` as a LangChain HITL
178
+ * resume (`{ decisions }`). Because a resumed run can suspend again on the next gated
179
+ * tool call, this loops until the graph completes with no pending interrupts.
180
+ *
181
+ * No-ops (yields nothing) when the agent does not support interrupts
182
+ * (`getPendingToolInterrupts`/`streamWithEventsResume` absent), so the lean agent and
183
+ * non-HITL configs are unaffected. Aborts (`signal`) propagate through the resumed stream.
184
+ */
185
+ private resolveToolInterruptsWithEvents;
44
186
  getAgent(): GthAgentInterface | null;
187
+ /**
188
+ * GS2-16 — reset the current turn's analytics tally on both the live agent and the runner's
189
+ * cached snapshot, so a new turn starts clean. Fail-soft (an agent without stats support is a
190
+ * no-op). Called at the top of each `processMessages` / `processMessagesWithEvents`.
191
+ */
192
+ private resetRunStats;
193
+ /** GS2-16 — read the live agent's run stats (fail-soft; empty tally if unavailable). */
194
+ private captureRunStats;
195
+ /**
196
+ * GS2-16 — the analytics harvested from the just-finished turn (token usage + invoked tools),
197
+ * to thread into the opt-in history recorder. Reads live from the agent when one is present,
198
+ * otherwise the snapshot captured at {@link cleanup} (the single-shot path reads post-cleanup).
199
+ * Never throws.
200
+ */
201
+ getRunStats(): GthRunStats;
45
202
  /**
46
203
  * Rotate the thread the runner drives by minting a fresh `runConfig` (new `thread_id`),
47
204
  * so subsequent turns start from an empty checkpointer thread rather than retrieving the
@@ -1,4 +1,12 @@
1
+ import { getEffectiveDevToolsConfig, getShellJudgeSettings, isShellAllowlistEnabled, isShellAllowlistPersisted, isShellJudgeEnabled, } from '#src/config.js';
1
2
  import { GthLangChainAgent } from '#src/core/GthLangChainAgent.js';
3
+ import { AllowlistStore, PersistedAllowlist, matchesApproval, } from '#src/core/shell/allowlist.js';
4
+ import { classifyCommand } from '#src/core/shell/arity.js';
5
+ import { normalizeCommand } from '#src/core/shell/normalize.js';
6
+ import { judgeShellCommand, mapVerdictToAction, } from '#src/core/shell/judge.js';
7
+ import { env } from '#src/utils/systemUtils.js';
8
+ import { getGslothConfigWritePath } from '#src/utils/fileUtils.js';
9
+ import { SHELL_ALLOWLIST_FILE } from '#src/constants.js';
2
10
  import { enhanceVertexUnauthorizedMessage } from '#src/utils/vertexaiUtils.js';
3
11
  import { getNewRunnableConfig } from '#src/utils/llmUtils.js';
4
12
  import { initDebugLogging, debugLog, debugLogError, debugLogObject, } from '#src/utils/debugUtils.js';
@@ -13,6 +21,47 @@ export class GthAgentRunner {
13
21
  config = null;
14
22
  runConfig = null;
15
23
  agentFactory;
24
+ /**
25
+ * Consumer hook invoked when a run suspends on a tool-approval interrupt (e.g. the opt-in
26
+ * `run_shell_command` confirmation). Set via {@link setToolApprovalCallback}; when unset the
27
+ * runner REJECTS pending tool calls rather than hanging or auto-approving — the safe default
28
+ * for non-interactive entrypoints (a scripted `exec` run with no TTY to prompt on).
29
+ */
30
+ toolApprovalCallback = null;
31
+ /** The command the runner was initialized for; selects which `devTools` config applies. */
32
+ command = undefined;
33
+ /**
34
+ * GS2-16 — snapshot of the last turn's analytics (token usage + invoked tools), captured from
35
+ * the agent at {@link cleanup} time. Needed because {@link runSingleShot} reads stats AFTER it
36
+ * has already called `cleanup()` (which nulls the agent); interactive callers read live via
37
+ * {@link getRunStats} before cleanup. Defaults to an empty tally.
38
+ */
39
+ lastRunStats = { tools: [] };
40
+ /**
41
+ * EXT-12 — runtime, session-scoped auto-approve flag driven by the `/auto-approve` (a.k.a.
42
+ * `/yolo`) slash command. Because the shell tool stays gated (in `interruptOn`) in the
43
+ * interactive `code` mode, this flag is consulted at the TOP of {@link decideToolApproval}:
44
+ * when ON, a gated `run_shell_command` is auto-approved WITHOUT prompting for the rest of this
45
+ * runner's life. Never persisted, but INITIALIZED at {@link init} from the static
46
+ * `run_shell_command.yolo` config knob — so a config that pre-enables auto-approval still keeps the
47
+ * tool gated and therefore toggleable (`/auto-approve off` restores the per-command prompt).
48
+ * It does NOT disable the hardline floor — catastrophic commands are still refused at exec time
49
+ * in `GthDevToolkit.executeCommand`.
50
+ */
51
+ sessionYolo = false;
52
+ /**
53
+ * EXT-9 Tier-2 session allow-list — approved command prefixes that auto-approve for the
54
+ * life of THIS runner instance. Instance-scoped (not module-global) so concurrent
55
+ * sessions (ACP / AG-UI multi-session) cannot stomp each other's approvals.
56
+ */
57
+ sessionAllowlist = new AllowlistStore();
58
+ /**
59
+ * EXT-9 Tier-2 persisted (`always`) allow-list, loaded lazily on first use from
60
+ * `.gsloth/.gsloth-settings/shell-allowlist.json`. Null until the shell tool is gated
61
+ * and the allow-list is enabled; null also when persistence is disabled by config.
62
+ */
63
+ persistedAllowlist = null;
64
+ persistedAllowlistLoaded = false;
16
65
  /**
17
66
  * @param agentFactory Produces the {@link GthAgentInterface} the runner drives.
18
67
  * Defaults to the lean {@link GthLangChainAgent} (core). `@gaunt-sloth/agent`
@@ -25,6 +74,38 @@ export class GthAgentRunner {
25
74
  this.agentFactory =
26
75
  agentFactory ?? ((status, agentResolvers) => new GthLangChainAgent(status, agentResolvers));
27
76
  }
77
+ /**
78
+ * Register the tool-approval handler the runner calls when a run suspends on a tool-approval
79
+ * interrupt (the interactive readline session wires a y/n prompt here). Pass `null` to clear.
80
+ * Without a handler the runner rejects pending tool calls (see {@link toolApprovalCallback}).
81
+ */
82
+ setToolApprovalCallback(callback) {
83
+ this.toolApprovalCallback = callback;
84
+ }
85
+ /**
86
+ * EXT-12 — flip the runtime, session-scoped auto-approve flag (the `/auto-approve` /
87
+ * `/yolo` slash command with no argument). When ON, gated `run_shell_command` calls
88
+ * auto-approve without prompting for the rest of this session; the hardline floor still applies
89
+ * at exec time. Returns the NEW state so the caller can render a notice. Session-scoped only —
90
+ * nothing is written to config.
91
+ */
92
+ toggleSessionYolo() {
93
+ this.sessionYolo = !this.sessionYolo;
94
+ return this.sessionYolo;
95
+ }
96
+ /**
97
+ * EXT-12 — set the session-scoped auto-approve flag explicitly (the `/auto-approve on|off`
98
+ * slash command). Idempotent; returns the NEW state so the caller can render a notice.
99
+ * Session-scoped only — nothing is written to config.
100
+ */
101
+ setSessionYolo(on) {
102
+ this.sessionYolo = on;
103
+ return this.sessionYolo;
104
+ }
105
+ /** EXT-12 — current state of the runtime session-scoped auto-approve flag (see {@link toggleSessionYolo}). */
106
+ isSessionYolo() {
107
+ return this.sessionYolo;
108
+ }
28
109
  /**
29
110
  * Init is split into a separate method. This may create a number of connections,
30
111
  * and we'd better have an instance by that moment, for the case things will go wrong,
@@ -32,6 +113,12 @@ export class GthAgentRunner {
32
113
  */
33
114
  async init(command, configIn, checkpointSaver) {
34
115
  this.config = configIn;
116
+ this.command = command;
117
+ // EXT-12 — seed the runtime auto-approve flag from the static `run_shell_command.yolo` config so a
118
+ // config that pre-enables auto-approval starts ON, while the shell tool stays gated (see
119
+ // GthDeepAgent) and therefore remains toggleable (`/auto-approve off`). Resolved per-command,
120
+ // mirroring where the shell tool is actually emitted; no effect where the tool is ungated.
121
+ this.sessionYolo = getEffectiveDevToolsConfig(configIn, command)?.shellYolo === true;
35
122
  // Initialize debug logging
36
123
  initDebugLogging(configIn.debugLog ?? false);
37
124
  debugLog(`Initializing GthAgentRunner with command: ${command || 'default'}`);
@@ -50,6 +137,8 @@ export class GthAgentRunner {
50
137
  if (!this.agent || !this.config || !this.runConfig) {
51
138
  throw new Error('AgentRunner not initialized. Call init() first.');
52
139
  }
140
+ // GS2-16: start this turn's analytics tally from zero (the runner is reused across turns).
141
+ this.resetRunStats();
53
142
  debugLog('Processing messages...');
54
143
  debugLogObject('Input Messages', messages);
55
144
  try {
@@ -60,10 +149,11 @@ export class GthAgentRunner {
60
149
  const stream = await this.agent.stream(messages, this.runConfig);
61
150
  let result = '';
62
151
  try {
63
- for await (const chunk of stream) {
64
- debugLogObject('Stream chunk', chunk);
65
- result += chunk;
66
- }
152
+ result = await this.drainTextStream(stream);
153
+ // A run may suspend on one or more tool-approval interrupts (run_shell_command).
154
+ // Resolve them in a loop: each resume can itself suspend again on the next gated
155
+ // tool call, so keep going until the graph completes with no pending interrupts.
156
+ result += await this.resolveToolInterrupts();
67
157
  }
68
158
  catch (streamError) {
69
159
  // Handle streaming-specific errors
@@ -101,6 +191,189 @@ export class GthAgentRunner {
101
191
  throw new Error(`Agent processing failed: ${enhancedMessage}`, error instanceof Error ? { cause: error } : undefined);
102
192
  }
103
193
  }
194
+ /**
195
+ * Accumulate a text stream into a single string. Extracted so {@link processMessages} and
196
+ * the interrupt-resume loop ({@link resolveToolInterrupts}) drain streams identically.
197
+ */
198
+ async drainTextStream(stream) {
199
+ let result = '';
200
+ for await (const chunk of stream) {
201
+ debugLogObject('Stream chunk', chunk);
202
+ result += chunk;
203
+ }
204
+ return result;
205
+ }
206
+ /**
207
+ * After a streamed run ends, resolve any tool-approval interrupts it suspended on. For each
208
+ * pending tool call the {@link toolApprovalCallback} is consulted (defaulting to REJECT when
209
+ * no handler is wired, so a non-interactive run never hangs or auto-approves); the collected
210
+ * decisions are then sent back via the agent's `streamResume` as a LangChain HITL resume
211
+ * (`{ decisions }`). Because a resumed run can suspend again on the next gated tool call, this
212
+ * loops until the graph completes with no pending interrupts. Returns the concatenated text
213
+ * streamed across all resume turns (empty when nothing was resumed).
214
+ *
215
+ * No-ops (returns '') when the agent does not support interrupts (`getPendingToolInterrupts`/
216
+ * `streamResume` absent), so the lean agent and non-HITL configs are unaffected.
217
+ */
218
+ async resolveToolInterrupts() {
219
+ const agent = this.agent;
220
+ const runConfig = this.runConfig;
221
+ if (!agent || !runConfig)
222
+ return '';
223
+ if (!agent.getPendingToolInterrupts || !agent.streamResume)
224
+ return '';
225
+ let resumedText = '';
226
+ // Bound the loop defensively so a misbehaving graph that re-suspends forever cannot spin.
227
+ for (let guard = 0; guard < 100; guard++) {
228
+ const pending = await agent.getPendingToolInterrupts(runConfig);
229
+ if (pending.length === 0)
230
+ break;
231
+ const decisions = [];
232
+ for (const tool of pending) {
233
+ decisions.push(await this.decideToolApproval(tool));
234
+ }
235
+ const stream = await agent.streamResume({ decisions }, runConfig);
236
+ resumedText += await this.drainTextStream(stream);
237
+ }
238
+ return resumedText;
239
+ }
240
+ /**
241
+ * Decide a single pending tool call (EXT-9 Tier-2). For the opt-in `run_shell_command`,
242
+ * consult the scoped allow-list FIRST: if the command's classified prefix is already
243
+ * approved (session or persisted `always`) and survives the safe-bin anti-widening
244
+ * re-validation, auto-approve SILENTLY (no human prompt). Otherwise fall through to the
245
+ * human callback; when the human grants `session`/`always` scope, record the command's
246
+ * classified prefix into the matching store so future flag-variants stop re-prompting.
247
+ *
248
+ * When no human callback is wired (non-TTY exec run) and nothing is allow-listed, reject —
249
+ * never auto-approve. Non-shell tools (or any tool when the allow-list is disabled) skip the
250
+ * allow-list and go straight to the human callback / default-reject, preserving prior behaviour.
251
+ *
252
+ * Hardline catastrophic commands remain refused at exec time regardless of any approval here
253
+ * (defense in depth in `GthDevToolkit.executeCommand`), so an allow-listed `rm -rf /` still
254
+ * cannot run.
255
+ */
256
+ async decideToolApproval(tool) {
257
+ const command = typeof tool.args?.command === 'string' ? tool.args.command : null;
258
+ const isShellCommand = tool.name === 'run_shell_command' && command !== null;
259
+ const allowlistApplies = isShellCommand && this.isShellAllowlistOn();
260
+ // EXT-12 — runtime session yolo (`/yolo`): when ON, auto-approve a gated shell command WITHOUT
261
+ // prompting, judging, or persisting. Scope `once` so nothing is written to the allow-list (the
262
+ // bypass is intentionally ephemeral and reversible). The hardline floor is NOT bypassed here —
263
+ // it is enforced at exec time in GthDevToolkit.executeCommand regardless of this decision, so a
264
+ // catastrophic command is still refused even under yolo.
265
+ if (isShellCommand && this.sessionYolo) {
266
+ return { type: 'approve', scope: 'once' };
267
+ }
268
+ // Auto-approve from the allow-list without prompting. The allow-list ALWAYS wins over the
269
+ // judge: a human-trusted prefix shouldn't pay for an LLM call on every variant.
270
+ if (allowlistApplies && this.isApprovedByAllowlist(command)) {
271
+ return { type: 'approve', scope: 'session' };
272
+ }
273
+ // EXT-10 — LLM-as-judge safety gate (default OFF). Runs BEFORE the human callback for a
274
+ // `run_shell_command` not already allow-listed: auto-approve clearly-safe (fatigue reducer),
275
+ // reject clearly-catastrophic (only when blockHigh), otherwise fall through to the human with
276
+ // the verdict attached. When disabled this is a no-op and behaviour is exactly EXT-9.
277
+ let safetyVerdict;
278
+ if (isShellCommand && command !== null && this.isShellJudgeOn()) {
279
+ const settings = getShellJudgeSettings(getEffectiveDevToolsConfig(this.config ?? undefined, this.command));
280
+ const verdict = await judgeShellCommand(command, this.config, {
281
+ home: env?.HOME,
282
+ });
283
+ const action = mapVerdictToAction(command, verdict, {
284
+ autoApproveLow: settings.autoApproveLow,
285
+ blockHigh: settings.blockHigh,
286
+ });
287
+ if (action === 'auto-approve') {
288
+ // Scope `once`: judge approvals are NEVER persisted to the allow-list.
289
+ return { type: 'approve', scope: 'once' };
290
+ }
291
+ if (action === 'reject') {
292
+ return {
293
+ type: 'reject',
294
+ message: `Safety judge blocked the command: ${verdict.reason}`,
295
+ };
296
+ }
297
+ // Escalate: carry the verdict to the human approval surface.
298
+ safetyVerdict = verdict;
299
+ }
300
+ if (!this.toolApprovalCallback) {
301
+ // No interactive handler (e.g. non-TTY exec run): reject rather than auto-approve.
302
+ return {
303
+ type: 'reject',
304
+ message: 'Tool call rejected: no interactive approval handler available.',
305
+ };
306
+ }
307
+ // Surface the judge's verdict to the human prompt (if the judge escalated) without mutating
308
+ // the original interrupt object the caller holds.
309
+ const pending = safetyVerdict ? { ...tool, safetyVerdict } : tool;
310
+ const decision = await this.toolApprovalCallback(pending);
311
+ // Persist the human's scoped grant so future variants of the same operation skip the prompt.
312
+ if (decision.type === 'approve' && allowlistApplies && command) {
313
+ this.recordApproval(command, decision.scope ?? 'once');
314
+ }
315
+ return decision;
316
+ }
317
+ /** Whether the EXT-10 LLM-as-judge safety gate is enabled for the active command's config. */
318
+ isShellJudgeOn() {
319
+ if (!this.config)
320
+ return false;
321
+ const devTools = getEffectiveDevToolsConfig(this.config, this.command);
322
+ return isShellJudgeEnabled(devTools);
323
+ }
324
+ /** Whether the EXT-9 Tier-2 allow-list is enabled for the active command's devTools config. */
325
+ isShellAllowlistOn() {
326
+ const devTools = getEffectiveDevToolsConfig(this.config ?? undefined, this.command);
327
+ return isShellAllowlistEnabled(devTools);
328
+ }
329
+ /**
330
+ * Lazily load (once per instance) the persisted `always` allow-list, unless persistence is
331
+ * disabled by config. Returns null when persistence is off so `always` grants behave as
332
+ * `session` (in-memory only).
333
+ */
334
+ getPersistedAllowlist() {
335
+ if (this.persistedAllowlistLoaded)
336
+ return this.persistedAllowlist;
337
+ this.persistedAllowlistLoaded = true;
338
+ const devTools = getEffectiveDevToolsConfig(this.config ?? undefined, this.command);
339
+ if (!isShellAllowlistPersisted(devTools)) {
340
+ this.persistedAllowlist = null;
341
+ return null;
342
+ }
343
+ try {
344
+ const filePath = getGslothConfigWritePath(SHELL_ALLOWLIST_FILE);
345
+ this.persistedAllowlist = new PersistedAllowlist(filePath);
346
+ }
347
+ catch (e) {
348
+ // Path/IO failure → behave as no persisted store (still safe: just prompts more).
349
+ debugLogError('Loading persisted shell allow-list', e);
350
+ this.persistedAllowlist = null;
351
+ }
352
+ return this.persistedAllowlist;
353
+ }
354
+ /** Check the command against the session + persisted stores (with anti-widening re-validation). */
355
+ isApprovedByAllowlist(command) {
356
+ return matchesApproval(command, {
357
+ session: this.sessionAllowlist,
358
+ always: this.getPersistedAllowlist() ?? undefined,
359
+ });
360
+ }
361
+ /**
362
+ * Record a human-granted approval at the given scope. `once` persists nothing. `session`
363
+ * adds the classified prefix to the in-memory store. `always` additionally persists it (or
364
+ * falls back to session-only when persistence is disabled).
365
+ */
366
+ recordApproval(command, scope) {
367
+ if (scope === 'once')
368
+ return;
369
+ const classification = classifyCommand(command, normalizeCommand);
370
+ if (!classification)
371
+ return; // unclassifiable (composition/redirection) → never remember.
372
+ this.sessionAllowlist.add(classification.prefix);
373
+ if (scope === 'always') {
374
+ this.getPersistedAllowlist()?.add(classification.prefix);
375
+ }
376
+ }
104
377
  /**
105
378
  * Event-stream counterpart to {@link processMessages}: drives the agent's typed
106
379
  * {@link AgentStreamEvent} path using the runner's own thread-bound `runConfig`, so a
@@ -111,19 +384,106 @@ export class GthAgentRunner {
111
384
  * underlying `streamWithEvents` ends cleanly on abort or `interrupt()`. The string
112
385
  * path's empty-stream retry/`invoke` fallback is intentionally NOT duplicated here — the
113
386
  * TUI renders the live event stream directly; revisit if empty-stream retries are needed.
387
+ *
388
+ * Tool-approval round-trip (EXT-11): after the stream ends, a gated `run_shell_command`
389
+ * leaves the graph suspended on a `humanInTheLoopMiddleware` interrupt rather than
390
+ * completing. This is the event-stream counterpart to the readline path's
391
+ * {@link resolveToolInterrupts}: it drains any pending interrupts through
392
+ * {@link decideToolApproval} (allow-list → judge → bridged human prompt), resumes via
393
+ * `streamWithEventsResume({ decisions })`, and loops until the graph completes with no
394
+ * pending interrupts — so the executed command's output renders into the TUI. Without
395
+ * this the TUI silently finalized an empty turn (approval gate was dead code on the
396
+ * event-stream path).
114
397
  */
115
398
  async *processMessagesWithEvents(messages, signal) {
116
399
  if (!this.agent || !this.config || !this.runConfig) {
117
400
  throw new Error('AgentRunner not initialized. Call init() first.');
118
401
  }
402
+ // GS2-16: start this turn's analytics tally from zero (the runner is reused across turns).
403
+ this.resetRunStats();
119
404
  debugLog('Processing messages (event stream)...');
120
405
  debugLogObject('Input Messages', messages);
121
406
  yield* this.agent.streamWithEvents(messages, this.runConfig, signal);
407
+ yield* this.resolveToolInterruptsWithEvents(signal);
408
+ }
409
+ /**
410
+ * Event-stream counterpart to {@link resolveToolInterrupts}: after a streamed run ends,
411
+ * resolve any tool-approval interrupts it suspended on, yielding the resumed run's typed
412
+ * {@link AgentStreamEvent}s so the renderer (the Ink TUI) shows the executed command's
413
+ * output. Each pending tool call is consulted via {@link decideToolApproval} — the SAME
414
+ * three-layer gate the readline path uses (allow-list auto-approve → EXT-10 judge →
415
+ * bridged human callback, defaulting to REJECT when no handler is wired) — and the
416
+ * collected decisions are sent back via `streamWithEventsResume` as a LangChain HITL
417
+ * resume (`{ decisions }`). Because a resumed run can suspend again on the next gated
418
+ * tool call, this loops until the graph completes with no pending interrupts.
419
+ *
420
+ * No-ops (yields nothing) when the agent does not support interrupts
421
+ * (`getPendingToolInterrupts`/`streamWithEventsResume` absent), so the lean agent and
422
+ * non-HITL configs are unaffected. Aborts (`signal`) propagate through the resumed stream.
423
+ */
424
+ async *resolveToolInterruptsWithEvents(signal) {
425
+ const agent = this.agent;
426
+ const runConfig = this.runConfig;
427
+ if (!agent || !runConfig)
428
+ return;
429
+ if (!agent.getPendingToolInterrupts || !agent.streamWithEventsResume)
430
+ return;
431
+ // Bound the loop defensively so a misbehaving graph that re-suspends forever cannot spin.
432
+ for (let guard = 0; guard < 100; guard++) {
433
+ if (signal?.aborted)
434
+ return;
435
+ const pending = await agent.getPendingToolInterrupts(runConfig);
436
+ if (pending.length === 0)
437
+ break;
438
+ const decisions = [];
439
+ for (const tool of pending) {
440
+ decisions.push(await this.decideToolApproval(tool));
441
+ }
442
+ yield* agent.streamWithEventsResume({ decisions }, runConfig, [], signal);
443
+ }
122
444
  }
123
445
  // noinspection JSUnusedGlobalSymbols
124
446
  getAgent() {
125
447
  return this.agent;
126
448
  }
449
+ /**
450
+ * GS2-16 — reset the current turn's analytics tally on both the live agent and the runner's
451
+ * cached snapshot, so a new turn starts clean. Fail-soft (an agent without stats support is a
452
+ * no-op). Called at the top of each `processMessages` / `processMessagesWithEvents`.
453
+ */
454
+ resetRunStats() {
455
+ this.lastRunStats = { tools: [] };
456
+ try {
457
+ this.agent?.resetRunStats?.();
458
+ }
459
+ catch {
460
+ /* fail-soft: analytics must never affect a run */
461
+ }
462
+ }
463
+ /** GS2-16 — read the live agent's run stats (fail-soft; empty tally if unavailable). */
464
+ captureRunStats() {
465
+ try {
466
+ const stats = this.agent?.getRunStats?.();
467
+ if (stats)
468
+ return stats;
469
+ }
470
+ catch {
471
+ /* fail-soft */
472
+ }
473
+ return { tools: [] };
474
+ }
475
+ /**
476
+ * GS2-16 — the analytics harvested from the just-finished turn (token usage + invoked tools),
477
+ * to thread into the opt-in history recorder. Reads live from the agent when one is present,
478
+ * otherwise the snapshot captured at {@link cleanup} (the single-shot path reads post-cleanup).
479
+ * Never throws.
480
+ */
481
+ getRunStats() {
482
+ if (this.agent) {
483
+ this.lastRunStats = this.captureRunStats();
484
+ }
485
+ return this.lastRunStats;
486
+ }
127
487
  /**
128
488
  * Rotate the thread the runner drives by minting a fresh `runConfig` (new `thread_id`),
129
489
  * so subsequent turns start from an empty checkpointer thread rather than retrieving the
@@ -139,6 +499,9 @@ export class GthAgentRunner {
139
499
  }
140
500
  async cleanup() {
141
501
  debugLog('Cleaning up GthAgentRunner...');
502
+ // GS2-16: snapshot the agent's run stats BEFORE nulling it, so a post-cleanup reader
503
+ // (runSingleShot records history after calling cleanup) still gets this turn's analytics.
504
+ this.lastRunStats = this.captureRunStats();
142
505
  if (this.agent && 'cleanup' in this.agent && typeof this.agent.cleanup === 'function') {
143
506
  await this.agent.cleanup();
144
507
  }