pi-claude-supervisor 0.7.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/src/decision-worker.ts +8 -5
- package/src/policy.ts +41 -12
- package/src/redaction.ts +3 -1
- package/src/supervisor.ts +68 -3
- package/src/worker/tmux-adapter.ts +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
|
|
5
|
+
## [0.7.2](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.1...v0.7.2) (2026-09-17)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **policy:** a redirection target is not a dynamic argument, and drop the regex twin of the token check ([94742c7](https://github.com/btnalit/pi-claude-supervisor/commit/94742c7c8c8c810843ddb62703d7b3c1fea1254f))
|
|
11
|
+
* **policy:** an environment prefix's dynamic value is not a dynamic argument ([b53e77c](https://github.com/btnalit/pi-claude-supervisor/commit/b53e77c6932851a1c88452bca38c8d5bdf1030b5))
|
|
12
|
+
* **policy:** scope the dynamic-argument veto to the statement holding the sensitive command ([5515a2b](https://github.com/btnalit/pi-claude-supervisor/commit/5515a2b0d746d3d3cd05b0ef4ba86602f92eb072))
|
|
13
|
+
* **redaction:** keep numeric token counts in usage events ([6d4e183](https://github.com/btnalit/pi-claude-supervisor/commit/6d4e1831b588b71175d581764b611e5ed5cbe664))
|
|
14
|
+
* **supervisor:** survive Claude's own background work in an interactive session ([d883b9a](https://github.com/btnalit/pi-claude-supervisor/commit/d883b9a2b187c55693ff2669a1ba4f7f949272d3))
|
|
15
|
+
|
|
5
16
|
## [0.7.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.0...v0.7.1) (2026-09-17)
|
|
6
17
|
|
|
7
18
|
|
package/package.json
CHANGED
package/src/decision-worker.ts
CHANGED
|
@@ -14,7 +14,7 @@ const MAX_DECISION_FIELD_BYTES = 8 * 1024;
|
|
|
14
14
|
export type DecisionAction =
|
|
15
15
|
| { action: "continue" | "redirect" | "answer"; message: string; reason: string; confidence?: number }
|
|
16
16
|
| { action: "allow_permission" | "deny_permission"; requestId: string; toolUseId: string; reason: string; confidence?: number }
|
|
17
|
-
| { action: "verify" | "stop" | "park" | "ask_human" | "noop"; reason: string; question?: string; confidence?: number }
|
|
17
|
+
| { action: "verify" | "stop" | "park" | "ask_human" | "noop" | "wait"; reason: string; question?: string; confidence?: number }
|
|
18
18
|
| { action: "retry"; reason: string; message?: string; confidence?: number };
|
|
19
19
|
|
|
20
20
|
export interface DecisionContext {
|
|
@@ -280,7 +280,7 @@ Current repair round: ${context.repairRound ?? 0}
|
|
|
280
280
|
Task specification: ${boundedJson(context.spec ?? { goal: context.task })}
|
|
281
281
|
|
|
282
282
|
Return exactly one JSON object and no markdown:
|
|
283
|
-
{"action":"continue|redirect|answer|allow_permission|deny_permission|verify|retry|stop|park|noop",...}
|
|
283
|
+
{"action":"continue|redirect|answer|allow_permission|deny_permission|verify|retry|stop|park|wait|noop",...}
|
|
284
284
|
For continue/redirect/answer include message and reason. For permission actions include
|
|
285
285
|
requestId and toolUseId. Retry may include a corrective message. Never choose allow_permission
|
|
286
286
|
for a command that crosses the remote push or main/integration merge boundary; the deterministic
|
|
@@ -300,7 +300,10 @@ Claude says it will stop; choose stop only for an explicit stop or technical con
|
|
|
300
300
|
Use park only when the task cannot safely produce a candidate because required evidence,
|
|
301
301
|
authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
|
|
302
302
|
wait for a human to be online. For an exited event choose verify, park or stop; a noop on an exited event is treated as
|
|
303
|
-
verify.
|
|
303
|
+
verify. Choose wait when the Worker's result says it is waiting for its own background agents,
|
|
304
|
+
tasks or monitors: their completion re-invokes the Worker automatically, a message would only
|
|
305
|
+
interrupt it, and the Supervisor asks you again if the Worker has not resumed within the wait
|
|
306
|
+
timeout. A completed turn or a permission request always requires a concrete action.`;
|
|
304
307
|
}
|
|
305
308
|
|
|
306
309
|
/**
|
|
@@ -544,7 +547,7 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
|
544
547
|
const value = distinct[0] as Record<string, unknown>;
|
|
545
548
|
const action = value.action;
|
|
546
549
|
if (typeof action !== "string") throw new Error("missing action");
|
|
547
|
-
const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "park", "ask_human", "noop"]);
|
|
550
|
+
const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "park", "ask_human", "noop", "wait"]);
|
|
548
551
|
if (!allowed.has(action)) throw new Error(`unsupported action: ${action}`);
|
|
549
552
|
const reason = typeof value.reason === "string" && value.reason.trim() ? boundedDecisionText(value.reason, "reason") : "no reason provided";
|
|
550
553
|
const confidence = value.confidence === undefined ? undefined : typeof value.confidence === "number" && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1
|
|
@@ -564,7 +567,7 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
|
564
567
|
if (action === "retry") {
|
|
565
568
|
return { action, reason, message: typeof value.message === "string" ? boundedDecisionText(value.message, "message") : undefined, confidence };
|
|
566
569
|
}
|
|
567
|
-
return { action: action as "verify" | "stop" | "park" | "ask_human" | "noop", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
|
|
570
|
+
return { action: action as "verify" | "stop" | "park" | "ask_human" | "noop" | "wait", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
|
|
568
571
|
} catch (error) {
|
|
569
572
|
return { action: "park", reason: `invalid Decision Worker action: ${error instanceof Error ? error.message : String(error)}` };
|
|
570
573
|
}
|
package/src/policy.ts
CHANGED
|
@@ -112,10 +112,6 @@ const deniedPatterns = [
|
|
|
112
112
|
// word (scripts/publish-package.mjs) is not a publication.
|
|
113
113
|
/\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu,
|
|
114
114
|
/\b(?:curl|wget)\b[\s\S]*(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--request(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|(?:^|\s)(?:-d|--data(?:[-a-z]*)(?:=|\s+)|--post-data(?:=|\s+)|--body-data(?:=|\s+)))[\s\S]*https?:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu,
|
|
115
|
-
// A repository/package command with a dynamic argument cannot be
|
|
116
|
-
// capability-checked (`git $ACTION origin main`); dynamic text elsewhere in a
|
|
117
|
-
// command is ordinary shell and is not a boundary concern.
|
|
118
|
-
/\b(?:git|gh|glab|hub|npm|pnpm|yarn)\b[^|;&\n]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
|
|
119
115
|
/--(?:allow-)?dangerously-skip-permissions\b/iu,
|
|
120
116
|
/--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
|
|
121
117
|
// Only the filesystem root itself; `rm -rf /abs/path/dist` is ordinary local work.
|
|
@@ -212,11 +208,10 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
212
208
|
|
|
213
209
|
// An argument the lexer cannot see through matters only where it could reach
|
|
214
210
|
// the boundary: a repository, package, network or remote-shell command, or an
|
|
215
|
-
// interpreter that would execute the expanded text
|
|
216
|
-
// ordinary local command (`for f in …; echo "$f"`)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (hasDynamicArgument && (hasGit || hasGhRemote || hasPackagePublication || dynamicSensitive || hasDynamicCommandName(tokens))) {
|
|
211
|
+
// interpreter that would execute the expanded text, in the same statement.
|
|
212
|
+
// Dynamic text in an ordinary local command (`for f in …; echo "$f"`) or in
|
|
213
|
+
// another statement (`npm test; echo "exit $?"`) is Claude's own business.
|
|
214
|
+
if ((hasDynamicArgument && hasDynamicCommandName(tokens)) || segmentsOf(tokens).some((segment) => hasDynamicSensitiveArgument(segment))) {
|
|
220
215
|
return { decision: "deny", reason: "a repository, package, network or shell command with a dynamic argument cannot be capability-checked" };
|
|
221
216
|
}
|
|
222
217
|
if (/\bgit\b[\s\S]*\b(?:push|merge(?!-)|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
|
|
@@ -250,6 +245,43 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
250
245
|
return undefined;
|
|
251
246
|
}
|
|
252
247
|
|
|
248
|
+
/** The statements of a command, split on `;`, `&&`, `||`, `|` and `&`. */
|
|
249
|
+
function segmentsOf(tokens: readonly ShellToken[]): ShellToken[][] {
|
|
250
|
+
const segments: ShellToken[][] = [[]];
|
|
251
|
+
for (const token of tokens) {
|
|
252
|
+
if (token.operator && SEGMENT_SPLIT_OPERATORS.has(token.value)) segments.push([]);
|
|
253
|
+
else segments.at(-1)!.push(token);
|
|
254
|
+
}
|
|
255
|
+
return segments.filter((segment) => segment.length > 0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** True when one statement combines a dynamic word with a command whose dynamic argument could reach the boundary. */
|
|
259
|
+
function hasDynamicSensitiveArgument(segment: readonly ShellToken[]): boolean {
|
|
260
|
+
// A leading `NAME=value` prefix sets the environment and a redirection
|
|
261
|
+
// target names a file; neither reaches the command's argv, so
|
|
262
|
+
// `npm_config_cache=$TMPDIR/x npm run check` and `git show HEAD:f > $OLD/f`
|
|
263
|
+
// are literal commands.
|
|
264
|
+
const words: ShellToken[] = [];
|
|
265
|
+
let afterRedirect = false;
|
|
266
|
+
for (const token of segment) {
|
|
267
|
+
if (token.operator) { afterRedirect = [">", ">>", "<", "<<<"].includes(token.value); continue; }
|
|
268
|
+
if (!afterRedirect) words.push(token);
|
|
269
|
+
afterRedirect = false;
|
|
270
|
+
}
|
|
271
|
+
let argvStart = 0;
|
|
272
|
+
while (argvStart < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[argvStart]!.value)) argvStart += 1;
|
|
273
|
+
if (!words.slice(argvStart).some((token) => token.dynamic)) return false;
|
|
274
|
+
const values = words.map((token) => token.value);
|
|
275
|
+
const lower = values.map((value) => value.toLowerCase());
|
|
276
|
+
const executables = lower.map((value) => value.split(/[\\/]/u).at(-1) ?? value);
|
|
277
|
+
const canonical = values.join(" ");
|
|
278
|
+
return values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^git-(?:send|receive|upload)-pack$/iu.test(value))
|
|
279
|
+
|| containsRemoteCliMutation(canonical)
|
|
280
|
+
|| (lower.some((value) => value === "npm" || value === "pnpm" || value === "yarn") && lower.includes("publish"))
|
|
281
|
+
|| executables.some((executable) => DYNAMIC_SENSITIVE_COMMANDS.has(executable))
|
|
282
|
+
|| (executables.includes("find") && lower.some((value) => FIND_EXEC_ACTIONS.has(value)));
|
|
283
|
+
}
|
|
284
|
+
|
|
253
285
|
/** A dynamic word in command position (`$CMD …`, `; $CMD`, `do . $file`) could name anything. */
|
|
254
286
|
function hasDynamicCommandName(tokens: readonly ShellToken[]): boolean {
|
|
255
287
|
let commandPosition = true;
|
|
@@ -326,9 +358,6 @@ function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): Policy
|
|
|
326
358
|
if (/\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu.test(canonical)) {
|
|
327
359
|
return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
|
|
328
360
|
}
|
|
329
|
-
if (/\b(?:git|gh|glab|hub|npm|pnpm|yarn)\b[^|;&\n]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu.test(canonical)) {
|
|
330
|
-
return { decision: "deny", reason: "a repository or package command with a dynamic argument cannot be capability-checked" };
|
|
331
|
-
}
|
|
332
361
|
return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
|
|
333
362
|
}
|
|
334
363
|
return { decision: "allow", reason: "command is allowed for unattended local development" };
|
package/src/redaction.ts
CHANGED
|
@@ -2,7 +2,9 @@ const sensitiveKeyPattern = /(password|secret|token|api[-_]?key|authorization|cr
|
|
|
2
2
|
|
|
3
3
|
/** Recursively redact credential-shaped values before persistence or model prompts. */
|
|
4
4
|
export function redactSensitive(value: unknown, key?: string): unknown {
|
|
5
|
-
|
|
5
|
+
// A credential is a string; a number or boolean under a sensitive-looking
|
|
6
|
+
// key (`totalTokens`, `contextTokens`, `maxTokens`) is a count, not a secret.
|
|
7
|
+
if (key && sensitiveKeyPattern.test(key) && typeof value === "string") return "[REDACTED]";
|
|
6
8
|
if (typeof value === "string") {
|
|
7
9
|
return value
|
|
8
10
|
.replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
|
package/src/supervisor.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface SupervisorStartOptions {
|
|
|
98
98
|
deadlineMs?: number;
|
|
99
99
|
/** Maximum time without worker output; defaults to 20 minutes. Set to 0 to disable. */
|
|
100
100
|
noOutputTimeoutMs?: number;
|
|
101
|
+
/** After a `wait` decision, how long the Worker may stay silent before the Decision Worker is asked again; defaults to 10 minutes. Set to 0 to disable. */
|
|
102
|
+
waitTimeoutMs?: number;
|
|
101
103
|
/** Human approval for a review-level worker command. */
|
|
102
104
|
approval?: { actor: "human"; reason: string };
|
|
103
105
|
/** Adopt an existing tmux session instead of starting a new worker. */
|
|
@@ -196,6 +198,9 @@ export class Supervisor {
|
|
|
196
198
|
#workerOutput = "";
|
|
197
199
|
#lastWorkerResult?: Record<string, unknown>;
|
|
198
200
|
#lastTurnCompleted?: WorkerEvent;
|
|
201
|
+
/** Armed by a `wait` decision: re-asks the Decision Worker if the Worker never resumes on its own. */
|
|
202
|
+
#waitTimer?: NodeJS.Timeout;
|
|
203
|
+
#waitTimeoutMs = 10 * 60_000;
|
|
199
204
|
#turn = 0;
|
|
200
205
|
#repairRound = 0;
|
|
201
206
|
#lastFindingSignature?: string;
|
|
@@ -324,6 +329,7 @@ export class Supervisor {
|
|
|
324
329
|
this.#turn = options.initialTurn ?? 0;
|
|
325
330
|
this.#deadlineMs = options.deadlineMs ?? 4 * 60 * 60_000;
|
|
326
331
|
this.#noOutputTimeoutMs = options.noOutputTimeoutMs ?? 20 * 60_000;
|
|
332
|
+
this.#waitTimeoutMs = options.waitTimeoutMs ?? 10 * 60_000;
|
|
327
333
|
this.#noOutputBaselineAt = undefined;
|
|
328
334
|
this.#verificationAbortController = undefined;
|
|
329
335
|
this.#progressPhase = undefined;
|
|
@@ -608,6 +614,8 @@ export class Supervisor {
|
|
|
608
614
|
if (this.#handledEvents.has(key)) return;
|
|
609
615
|
try {
|
|
610
616
|
let skipDecisionNotify = false;
|
|
617
|
+
// Any fresh Worker activity supersedes a pending wait.
|
|
618
|
+
this.#clearWaitTimer();
|
|
611
619
|
if (event.type === "turn_completed") { this.#lastWorkerResult = event.result; this.#lastTurnCompleted = event; }
|
|
612
620
|
// Do not call #pollInternal from within a deferred retry: it would
|
|
613
621
|
// recurse back into #retryDeferredWorkerEvents through #pollInternal's
|
|
@@ -901,9 +909,16 @@ export class Supervisor {
|
|
|
901
909
|
return;
|
|
902
910
|
}
|
|
903
911
|
if (action.action === "continue" || action.action === "redirect" || action.action === "answer") {
|
|
912
|
+
if (await this.#decisionIsStale(event)) return;
|
|
904
913
|
await this.#sendInternal(action.message);
|
|
905
914
|
return;
|
|
906
915
|
}
|
|
916
|
+
if (action.action === "wait") {
|
|
917
|
+
// The Worker will be re-invoked by its own background work; send
|
|
918
|
+
// nothing, but re-ask if it stays silent for the wait timeout.
|
|
919
|
+
this.#armWaitTimer(event);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
907
922
|
if (action.action === "verify") {
|
|
908
923
|
if (this.#machine.state === "waiting" && canRepairInPlace(this.#adapter)) {
|
|
909
924
|
this.#machine.transition("verifying");
|
|
@@ -938,12 +953,57 @@ export class Supervisor {
|
|
|
938
953
|
return;
|
|
939
954
|
}
|
|
940
955
|
if (action.action === "retry") {
|
|
941
|
-
if (action.message?.trim()) await this.#
|
|
942
|
-
|
|
956
|
+
if (!action.message?.trim()) { await this.#parkCandidate(`Retry requires a concrete corrective instruction: ${action.reason}`, event); return; }
|
|
957
|
+
if (await this.#decisionIsStale(event)) return;
|
|
958
|
+
await this.#sendInternal(action.message);
|
|
943
959
|
}
|
|
944
960
|
});
|
|
945
961
|
}
|
|
946
962
|
|
|
963
|
+
/**
|
|
964
|
+
* A decision about a completed turn is stale once the Worker has started a
|
|
965
|
+
* new turn on its own (a background agent, task or monitor of its own
|
|
966
|
+
* re-invoked it). Sending then would be refused by the adapter; it is not a
|
|
967
|
+
* failure of anything, so record it and let the next turn drive a new decision.
|
|
968
|
+
*/
|
|
969
|
+
async #decisionIsStale(event: WorkerEvent): Promise<boolean> {
|
|
970
|
+
const handle = this.#handle;
|
|
971
|
+
if (!handle || event.type !== "turn_completed") return false;
|
|
972
|
+
const status = await this.#adapter.getStatus(handle).catch(() => undefined);
|
|
973
|
+
if (!status || status.activeRequests === undefined || status.activeRequests === 0) return false;
|
|
974
|
+
await this.#appendEvent({
|
|
975
|
+
type: "decision_ignored",
|
|
976
|
+
taskId: this.#task?.taskId,
|
|
977
|
+
workerId: handle.id,
|
|
978
|
+
data: { reason: "worker resumed on its own before the decision arrived", eventType: event.type },
|
|
979
|
+
}).catch(() => {});
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#armWaitTimer(event: WorkerEvent): void {
|
|
984
|
+
this.#clearWaitTimer();
|
|
985
|
+
if (this.#waitTimeoutMs <= 0) return;
|
|
986
|
+
this.#waitTimer = setTimeout(() => {
|
|
987
|
+
this.#waitTimer = undefined;
|
|
988
|
+
void this.#exclusive(async () => {
|
|
989
|
+
if (!this.#decision || !this.#automation || this.#humanRequired || this.#machine.state !== "waiting" || this.#lastTurnCompleted !== event) return;
|
|
990
|
+
const handle = this.#handle;
|
|
991
|
+
if (!handle) return;
|
|
992
|
+
const status = await this.#adapter.getStatus(handle).catch(() => undefined);
|
|
993
|
+
if (status?.activeRequests) return;
|
|
994
|
+
await this.#appendEvent({ type: "wait_expired", taskId: this.#task?.taskId, workerId: handle.id, data: { waitTimeoutMs: this.#waitTimeoutMs } }).catch(() => {});
|
|
995
|
+
this.#decision.updateContext({ state: this.#machine.state, turn: this.#turn, repairRound: this.#repairRound });
|
|
996
|
+
this.#decision.replay?.(event);
|
|
997
|
+
}).catch(() => { /* the watchdog still covers a silent Worker */ });
|
|
998
|
+
}, this.#waitTimeoutMs);
|
|
999
|
+
this.#waitTimer.unref?.();
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
#clearWaitTimer(): void {
|
|
1003
|
+
if (this.#waitTimer) clearTimeout(this.#waitTimer);
|
|
1004
|
+
this.#waitTimer = undefined;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
947
1007
|
/**
|
|
948
1008
|
* Record, best effort, that the task's current branch diverged from the
|
|
949
1009
|
* last one observed. The task is anchored to its baseline commit, not to a
|
|
@@ -1069,7 +1129,11 @@ export class Supervisor {
|
|
|
1069
1129
|
this.#humanRequired = false;
|
|
1070
1130
|
this.#humanGate = undefined;
|
|
1071
1131
|
await this.#appendEvent({ type: "automation_resumed", taskId: this.#task?.taskId, workerId: this.#handle?.id });
|
|
1072
|
-
if (this.#decision && this.#machine.state === "waiting" && this.#lastTurnCompleted) {
|
|
1132
|
+
if (this.#decision && this.#machine.state === "waiting" && this.#lastTurnCompleted && this.#handle) {
|
|
1133
|
+
// Replay the last completed turn only if the Worker is really idle; if
|
|
1134
|
+
// it has resumed on its own, its next Stop brings a fresh turn.
|
|
1135
|
+
const status = await this.#adapter.getStatus(this.#handle).catch(() => undefined);
|
|
1136
|
+
if (status?.activeRequests) return;
|
|
1073
1137
|
this.#decision.updateContext({ state: this.#machine.state, turn: this.#turn, repairRound: this.#repairRound });
|
|
1074
1138
|
this.#decision.replay?.(this.#lastTurnCompleted);
|
|
1075
1139
|
}
|
|
@@ -1727,6 +1791,7 @@ export class Supervisor {
|
|
|
1727
1791
|
#clearWatchdog(): void {
|
|
1728
1792
|
if (this.#watchdog) clearInterval(this.#watchdog);
|
|
1729
1793
|
this.#watchdog = undefined;
|
|
1794
|
+
this.#clearWaitTimer();
|
|
1730
1795
|
}
|
|
1731
1796
|
|
|
1732
1797
|
async #checkWatchdog(): Promise<void> {
|
|
@@ -2003,7 +2003,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
2003
2003
|
record.pendingSentMessages.splice(matchedIndex, 1);
|
|
2004
2004
|
return {};
|
|
2005
2005
|
}
|
|
2006
|
-
|
|
2006
|
+
// Claude Code delivers its own background-task, monitor and agent
|
|
2007
|
+
// completions through this hook as a user-role message; nobody typed it.
|
|
2008
|
+
if (!isClaudeRuntimePrompt(prompt)) {
|
|
2009
|
+
this.#emit(record, { type: "human_input", handle: record.handle, text: boundTextHead(prompt, 4_096) });
|
|
2010
|
+
}
|
|
2007
2011
|
record.activeRequests = 1;
|
|
2008
2012
|
record.inputAt = Date.now();
|
|
2009
2013
|
record.lastInputAt = new Date().toISOString();
|
|
@@ -2277,6 +2281,11 @@ function normalizeForMatch(value: string): string {
|
|
|
2277
2281
|
return value.trim().replace(/\s+/gu, " ");
|
|
2278
2282
|
}
|
|
2279
2283
|
|
|
2284
|
+
/** True for a prompt Claude Code injected itself (`<task-notification>`, `<system-reminder>`), which a human never typed. */
|
|
2285
|
+
function isClaudeRuntimePrompt(prompt: string): boolean {
|
|
2286
|
+
return /^\s*<(?:task-notification|system-reminder)\b/u.test(prompt);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2280
2289
|
/**
|
|
2281
2290
|
* A UserPromptSubmit hook reports the prompt as Claude's TUI captured it,
|
|
2282
2291
|
* which can reflow long pasted text. Treat it as the adapter's own send when
|