pi-crew 0.11.24 → 0.11.26
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 +72 -0
- package/README.md +2 -2
- package/agents/designer.md +23 -0
- package/agents/orchestrator.md +17 -0
- package/agents/verifier.md +1 -1
- package/dist/index.mjs +568 -559
- package/{workflows/chain.workflow.md → docs/chain-workflow.md} +8 -0
- package/docs/dynamic-workflows.md +23 -0
- package/docs/publishing.md +34 -5
- package/docs/troubleshooting.md +50 -2
- package/docs/usage.md +7 -2
- package/install.mjs +2 -1
- package/package.json +4 -2
- package/skills/read-only-explorer/SKILL.md +15 -5
- package/skills/real-test-pi-crew/SKILL.md +7 -7
- package/src/config/config-validation.ts +58 -0
- package/src/config/config.ts +90 -5
- package/src/config/env-vars.ts +14 -7
- package/src/config/role-tools.ts +7 -5
- package/src/config/types.ts +4 -1
- package/src/extension/crew-cleanup.ts +6 -0
- package/src/extension/project-init.ts +19 -1
- package/src/extension/register.ts +68 -30
- package/src/extension/registration/artifact-cleanup.ts +52 -11
- package/src/extension/registration/commands/manage.ts +9 -2
- package/src/extension/registration/hook-registration.ts +3 -0
- package/src/extension/registration/lazy-configurers.ts +2 -0
- package/src/extension/registration/lifecycle-handlers.ts +79 -18
- package/src/extension/registration/render-loop.ts +27 -2
- package/src/extension/registration/tool-loop-guard.ts +5 -3
- package/src/extension/registration/verify-gate-tool.ts +7 -3
- package/src/extension/team-tool/api/read.ts +12 -3
- package/src/extension/team-tool/cancel.ts +11 -0
- package/src/extension/team-tool/config-patch.ts +56 -11
- package/src/extension/team-tool/destructive-gate.ts +72 -12
- package/src/extension/team-tool/dispatch/manage.ts +9 -1
- package/src/extension/team-tool/handle-schedule.ts +38 -4
- package/src/extension/team-tool/handle-settings.ts +76 -6
- package/src/extension/team-tool/health-monitor.ts +14 -2
- package/src/extension/team-tool/lifecycle-actions.ts +28 -10
- package/src/extension/team-tool/respond.ts +12 -5
- package/src/extension/team-tool/run-deadline.ts +29 -2
- package/src/extension/team-tool/run.ts +11 -0
- package/src/extension/team-tool/status.ts +64 -8
- package/src/extension/team-tool/workflow-manage.ts +5 -1
- package/src/extension/team-tool.ts +3 -0
- package/src/i18n.ts +18 -14
- package/src/prompt/inbox-poll.ts +14 -2
- package/src/prompt/message-tool.ts +1 -1
- package/src/prompt/prompt-runtime.ts +25 -3
- package/src/prompt/scratchpad-lifecycle.ts +14 -13
- package/src/prompt/surface-worker.ts +35 -9
- package/src/runtime/broker/crew-broker.ts +113 -111
- package/src/runtime/broker/delegate/delegate-abort.ts +118 -0
- package/src/runtime/broker/delegate/delegate-event.ts +3 -1
- package/src/runtime/broker/delegate/delegate-finalize.ts +179 -0
- package/src/runtime/broker/protocol/wait-resolve.ts +71 -5
- package/src/runtime/budget-enforcement.ts +26 -6
- package/src/runtime/chain-parser.ts +5 -3
- package/src/runtime/chain-runner.ts +41 -22
- package/src/runtime/child-pi/mock-fixtures.ts +12 -5
- package/src/runtime/compaction/tool-output-pruner.ts +8 -3
- package/src/runtime/delegate-spawn.ts +61 -8
- package/src/runtime/dispatch-batch.ts +10 -4
- package/src/runtime/effectiveness.ts +31 -3
- package/src/runtime/goal-workflow/plan-templates.ts +2 -2
- package/src/runtime/live-session/live-agent-control.ts +89 -6
- package/src/runtime/live-session/live-agent-manager.ts +94 -10
- package/src/runtime/live-session/live-session-runtime.ts +9 -2
- package/src/runtime/model/model-fallback.ts +6 -9
- package/src/runtime/model/pi-args.ts +3 -3
- package/src/runtime/model/runtime-policy.ts +4 -2
- package/src/runtime/model/runtime-resolver.ts +6 -2
- package/src/runtime/process/orphan-reaper.ts +143 -0
- package/src/runtime/process-status.ts +38 -16
- package/src/runtime/resilient-edit.ts +170 -93
- package/src/runtime/role-permission.ts +3 -1
- package/src/runtime/run-tracker.ts +9 -1
- package/src/runtime/sanitize/worker-text.ts +98 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +82 -28
- package/src/runtime/spawn-policy.ts +190 -8
- package/src/runtime/stale-reconciler.ts +36 -1
- package/src/runtime/subagent-manager.ts +29 -0
- package/src/runtime/surface/herdr-provider.ts +31 -1
- package/src/runtime/task-output-context.ts +58 -11
- package/src/runtime/task-runner/post-execution.ts +150 -0
- package/src/runtime/task-runner/prompt-builder.ts +41 -8
- package/src/runtime/task-runner.ts +16 -3
- package/src/runtime/verification/classifier-pre-gate.ts +27 -6
- package/src/runtime/verification/verify-gate.ts +153 -30
- package/src/state/atomic-write.ts +73 -4
- package/src/state/contracts.ts +7 -0
- package/src/state/coordination/mailbox.ts +40 -11
- package/src/state/event-log/event-log-rotation.ts +28 -6
- package/src/state/event-log/poison-marker.ts +16 -1
- package/src/state/stores/artifact-store.ts +181 -6
- package/src/state/stores/manifest-io.ts +126 -8
- package/src/state/stores/state-store.ts +30 -31
- package/src/state/types.ts +13 -0
- package/src/state/usage.ts +41 -0
- package/src/ui/run-dashboard.ts +3 -3
- package/src/ui/settings-overlay.ts +19 -1
- package/src/utils/internal-error.ts +5 -1
- package/src/utils/timer-limits.ts +30 -0
- package/src/utils/token-counter.ts +47 -0
- package/src/workflows/discover-workflows.ts +9 -1
- package/src/worktree/worktree-manager.ts +128 -18
- package/workflows/default.workflow.md +1 -1
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
|
@@ -15,6 +15,7 @@ import { agentOutputPath, readCrewAgentEventsCursor, readCrewAgentStatus, readCr
|
|
|
15
15
|
import { readForegroundControlStatus, writeForegroundInterruptRequest } from "../../../runtime/foreground-control.ts";
|
|
16
16
|
import { probeLiveSessionRuntime } from "../../../runtime/live-session/live-session-runtime.ts";
|
|
17
17
|
import { resolveCrewRuntime } from "../../../runtime/model/runtime-resolver.ts";
|
|
18
|
+
import { fenceWorkerText } from "../../../runtime/sanitize/worker-text.ts";
|
|
18
19
|
import { readEvents, readEventsCursor } from "../../../state/event-log/event-log.ts";
|
|
19
20
|
import { globMatch } from "../../../utils/glob-match.ts";
|
|
20
21
|
import { resolveRealContainedPath } from "../../../utils/safe-paths.ts";
|
|
@@ -201,7 +202,11 @@ export const handleGetAgentResult: ApiOperationHandler = (hctx) => {
|
|
|
201
202
|
true,
|
|
202
203
|
);
|
|
203
204
|
const task = loaded.tasks.find((item) => item.id === agent.taskId);
|
|
204
|
-
const
|
|
205
|
+
const artifactText = safeReadContainedFile(loaded.manifest.artifactsRoot, task?.resultArtifact?.path);
|
|
206
|
+
// R38-C1 (EP1 seed): result-artifact bytes are worker-authored — fence them
|
|
207
|
+
// before they enter the privileged session. The no-artifact fallback (agent
|
|
208
|
+
// record JSON) is runtime-owned and stays unfenced.
|
|
209
|
+
const text = artifactText !== undefined ? fenceWorkerText(artifactText, { source: "agent-result" }) : JSON.stringify(agent, null, 2);
|
|
205
210
|
return result(text, {
|
|
206
211
|
action: "api",
|
|
207
212
|
status: "ok",
|
|
@@ -294,7 +299,9 @@ export const handleReadAgentTranscript: ApiOperationHandler = (hctx) => {
|
|
|
294
299
|
const fallbackText = artifactText ? "" : (safeReadContainedFile(loaded.manifest.stateRoot, fallbackPath) ?? "");
|
|
295
300
|
const transcriptPath = artifactText ? artifactTranscriptPath : fallbackPath;
|
|
296
301
|
const text = artifactText || fallbackText;
|
|
297
|
-
|
|
302
|
+
// R38-C1 (EP1 seed): transcript/output.log bytes are worker-authored — fence
|
|
303
|
+
// them; the no-transcript fallback is runtime-owned and stays unfenced.
|
|
304
|
+
return result(text ? fenceWorkerText(text, { source: "agent-transcript" }) : `(no transcript at ${transcriptPath})`, {
|
|
298
305
|
action: "api",
|
|
299
306
|
status: "ok",
|
|
300
307
|
runId: loaded.manifest.runId,
|
|
@@ -403,7 +410,9 @@ export const handleDiff: ApiOperationHandler = (hctx) => {
|
|
|
403
410
|
const content = safeReadContainedFile(loaded.manifest.artifactsRoot, artifact.path);
|
|
404
411
|
if (content) {
|
|
405
412
|
const display = content.length > 4000 ? content.slice(0, 4000) + "\n... (truncated)" : content;
|
|
406
|
-
|
|
413
|
+
// R38-C1 (EP1 seed): diff/patch bytes are worker-authored — fence them
|
|
414
|
+
// per artifact (per-op fencing; headers stay runtime-owned).
|
|
415
|
+
parts.push(`\n--- ${artifact.path} ---\n${fenceWorkerText(display, { source: "diff" })}`);
|
|
407
416
|
}
|
|
408
417
|
}
|
|
409
418
|
return result(parts.join("\n"), {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { appendHookEvent, executeHook } from "../../hooks/registry.ts";
|
|
2
|
+
import { abortRunDelegates } from "../../runtime/broker/delegate/delegate-abort.ts";
|
|
2
3
|
import { killProcessPid } from "../../runtime/child-pi/child-pi.ts";
|
|
3
4
|
import { recordFromTask, saveCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
4
5
|
import { writeForegroundInterruptRequest } from "../../runtime/foreground-control.ts";
|
|
@@ -340,6 +341,16 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
340
341
|
// still signals synchronously here — while we hold the lock — so the async
|
|
341
342
|
// background runner cannot overwrite the cancelled state we write below.
|
|
342
343
|
if (fresh.manifest.status === "running") {
|
|
344
|
+
// M4-R1(c) (fix-wave 6 residual, M3-A / R43-N3): the run is going
|
|
345
|
+
// terminal — abort its in-flight DELEGATE GRANDCHILDREN too. They live
|
|
346
|
+
// in the broker's abort registry (a separate, process-local lifetime),
|
|
347
|
+
// so without this they outlive the cancel by up to their own 24h
|
|
348
|
+
// deadline while still spending tokens and holding nested slots. Exact
|
|
349
|
+
// wiring budget-enforcement.ts terminaliseRunWithDrain already uses;
|
|
350
|
+
// abortRunDelegates is SYNC + never throws (0 when no broker aborter is
|
|
351
|
+
// installed — headless/worker-side cancels keep the pre-existing
|
|
352
|
+
// degrade, never a new failure path).
|
|
353
|
+
abortRunDelegates(fresh.manifest.runId, "run-cancelled");
|
|
343
354
|
void terminateLiveAgentsForRun(fresh.manifest.runId, "cancelled", appendEvent, fresh.manifest.eventsPath).catch((error) =>
|
|
344
355
|
logInternalError("team-tool.handleCancel.terminateLiveAgents", error, `runId=${fresh.manifest.runId}`),
|
|
345
356
|
);
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { effectiveAutonomousConfig, type PiTeamsAutonomousConfig, type PiTeamsConfig, parseConfig } from "../../config/config.ts";
|
|
2
|
+
import { stripDangerousKeysDeep } from "../../config/config-validation.ts";
|
|
3
|
+
import { collectSensitiveConfigPaths } from "../../schema/sensitive-config-paths.ts";
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Recursively strip dangerous prototype-pollution keys from all levels of an
|
|
7
|
+
* object. M1-1: delegates to the shared proto-safe helpers (config-validation.ts,
|
|
8
|
+
* case-insensitive
|
|
9
|
+
* DANGEROUS_OBJECT_KEYS superset) so every config-patch parse boundary applies
|
|
10
|
+
* ONE canonical drop rule — config patch, autonomy patch, and
|
|
11
|
+
* effectiveRunConfig all flow through here or through parseConfig's asRecord.
|
|
12
|
+
*/
|
|
6
13
|
export function sanitizeObject<T>(obj: T): T {
|
|
7
|
-
|
|
8
|
-
if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item)) as T;
|
|
9
|
-
const safe: Record<string, unknown> = {};
|
|
10
|
-
for (const key of Object.keys(obj as Record<string, unknown>)) {
|
|
11
|
-
if (DANGEROUS_KEYS.has(key)) continue;
|
|
12
|
-
safe[key] = sanitizeObject((obj as Record<string, unknown>)[key]);
|
|
13
|
-
}
|
|
14
|
-
return safe as T;
|
|
14
|
+
return stripDangerousKeysDeep(obj);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function autonomousPatchFromConfig(config: unknown): PiTeamsAutonomousConfig {
|
|
@@ -24,8 +24,53 @@ export function configPatchFromConfig(config: unknown): PiTeamsConfig {
|
|
|
24
24
|
return parseConfig(config);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* M1-8 (R45-S2): drop the schema's sensitive-marked config paths from the
|
|
29
|
+
* RUN-PARAM tier. `params.config` is worker/LLM-influenced tool input — the
|
|
30
|
+
* same fields a project file may not set (user-only kill-switches and
|
|
31
|
+
* execution-shaping knobs: executeWorkers, runtime.mode/isolationPolicy,
|
|
32
|
+
* worktree.setupHook/seedPaths, agents.overrides, otlp.*, policy.*,
|
|
33
|
+
* autonomous.*, goalWrap, …) must not be settable through run params either.
|
|
34
|
+
*
|
|
35
|
+
* Drops mutate the FRESH parseConfig output only — never the caller's input
|
|
36
|
+
* and never `base` (the loaded user/project config, already project-tier
|
|
37
|
+
* sanitized at load). Sensitive keys are dropped SILENTLY: the drop is the
|
|
38
|
+
* contract, and the persisted `manifest.runConfig` records the effective
|
|
39
|
+
* (post-drop) config for audit. Precedence note (P4 pin): with
|
|
40
|
+
* `executeWorkers` dropped from the patch, a user's `executeWorkers:false`
|
|
41
|
+
* survives untouched — the kill-switch cannot be flipped back on via run
|
|
42
|
+
* params (runtime-resolver reads the merged effective config).
|
|
43
|
+
*
|
|
44
|
+
* Conditional carve-out (mirrors the project tier's CONDITIONAL_PROJECT_DROPS
|
|
45
|
+
* doctrine: loosening values dropped, tightening values survive): a run param
|
|
46
|
+
* may still TIGHTEN `runtime.mode` to "scaffold" — the documented params
|
|
47
|
+
* dry-run channel ("Set runtime.mode=scaffold … only for dry runs", run tool
|
|
48
|
+
* banner) spawns no workers and is the SAFE direction. Loosening modes
|
|
49
|
+
* (auto/child-process/live-session — the R45-S2 escalation threat) are
|
|
50
|
+
* dropped like every other sensitive value.
|
|
51
|
+
*/
|
|
52
|
+
const RUNPARAM_CONDITIONAL_KEEP: Record<string, (value: unknown) => boolean> = {
|
|
53
|
+
"runtime.mode": (value) => value === "scaffold",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function dropSensitivePatchPaths(patch: Record<string, unknown>): void {
|
|
57
|
+
for (const dotted of collectSensitiveConfigPaths()) {
|
|
58
|
+
const parts = dotted.split(".");
|
|
59
|
+
let node: Record<string, unknown> | undefined = patch;
|
|
60
|
+
for (let i = 0; node !== undefined && i < parts.length - 1; i++) {
|
|
61
|
+
const next: unknown = node[parts[i]!];
|
|
62
|
+
node = next !== null && typeof next === "object" && !Array.isArray(next) ? (next as Record<string, unknown>) : undefined;
|
|
63
|
+
}
|
|
64
|
+
if (node === undefined) continue;
|
|
65
|
+
const leaf = parts[parts.length - 1]!;
|
|
66
|
+
if (RUNPARAM_CONDITIONAL_KEEP[dotted]?.(node[leaf])) continue;
|
|
67
|
+
delete node[leaf];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
export function effectiveRunConfig(base: PiTeamsConfig, rawOverride: unknown): PiTeamsConfig {
|
|
28
72
|
const patch = sanitizeObject(parseConfig(rawOverride));
|
|
73
|
+
dropSensitivePatchPaths(patch as Record<string, unknown>);
|
|
29
74
|
return {
|
|
30
75
|
...base,
|
|
31
76
|
...patch,
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* 5. Otherwise → blocked with a reason telling the user what to pass.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
24
25
|
import { currentCrewDepth } from "../../runtime/model/pi-args.ts";
|
|
25
26
|
|
|
26
27
|
export const DESTRUCTIVE_TEAM_ACTIONS = new Set([
|
|
@@ -91,6 +92,19 @@ export function shouldBlockDestructiveTeamAction(action: string | undefined, inp
|
|
|
91
92
|
* - `create` / — when resolved scope is `user` (create defaults to user;
|
|
92
93
|
* - `update` update is gated at the resolved resource's scope — see
|
|
93
94
|
* the post-`resolveMutable` call in management.ts).
|
|
95
|
+
* - `workflow-` — ALWAYS (fix-wave 6, R11-S1): .dwf.ts scripts are
|
|
96
|
+
* `create`/ arbitrary code auto-loaded by every future session —
|
|
97
|
+
* `save`/ project tier included (`.crew/workflows/` is discovered
|
|
98
|
+
* `delete` per-repo) and delete reaches the user tier. This is a
|
|
99
|
+
* deliberate exception to "project stays worker-reachable".
|
|
100
|
+
* workflow-get/list (reads) stay open.
|
|
101
|
+
* - `import` / — at resolved scope `user` only (project/default open,
|
|
102
|
+
* - `export` mirroring handleImport's project default).
|
|
103
|
+
*
|
|
104
|
+
* Scope source (fix-wave 6, R11-F1): the canonical carrier is `config.scope`
|
|
105
|
+
* — the ONE key every scope-bearing handler reads. Top-level `params.scope`
|
|
106
|
+
* is ignored by the gate (as it already is by the handlers) so divergent
|
|
107
|
+
* carriers cannot smuggle a user-tier write past the gate.
|
|
94
108
|
*
|
|
95
109
|
* Reads (config/status/list, settings list/get, schedule `scheduled`) are
|
|
96
110
|
* never gated. `dryRun` previews that write nothing pass through, mirroring
|
|
@@ -112,7 +126,7 @@ export class RootSessionRequiredError extends Error {
|
|
|
112
126
|
* in-repo (broker tests).
|
|
113
127
|
*/
|
|
114
128
|
export function isRootSession(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
115
|
-
if (
|
|
129
|
+
if (getCrewEnv("PI_CREW_KIND", env) === "subagent") return false;
|
|
116
130
|
try {
|
|
117
131
|
return currentCrewDepth(env) === 0;
|
|
118
132
|
} catch {
|
|
@@ -142,7 +156,9 @@ export function assertRootOnlyAction(
|
|
|
142
156
|
new RootSessionRequiredError(
|
|
143
157
|
`root-session required: team action '${action}'${subcommand ? ` (${subcommand})` : ""} ${what}. ` +
|
|
144
158
|
"This session is a pi-crew worker (PI_CREW_KIND/subagent or depth>0); user-tier state persists beyond the task and is reserved for the user's root pi session. " +
|
|
145
|
-
(what.includes("project")
|
|
159
|
+
(what.includes("project")
|
|
160
|
+
? ""
|
|
161
|
+
: "Re-run with config: {scope: 'project'} for project-local effects, or from the root session."),
|
|
146
162
|
);
|
|
147
163
|
switch (action) {
|
|
148
164
|
case "schedule":
|
|
@@ -171,6 +187,24 @@ export function assertRootOnlyAction(
|
|
|
171
187
|
case "update":
|
|
172
188
|
if (scope === "user") return detail("rewrites a user-tier resource loaded by every future session");
|
|
173
189
|
return undefined;
|
|
190
|
+
case "workflow-create":
|
|
191
|
+
case "workflow-save":
|
|
192
|
+
// R11-S1 (fix-wave 6 M1-3): a .dwf.ts is arbitrary code auto-loaded by
|
|
193
|
+
// every future session in the discovery dirs — project tier included
|
|
194
|
+
// (`.crew/workflows/` of this repo). Unconditional for workers, like
|
|
195
|
+
// autonomy: no worker-reachable scope (confirm:true is caller-
|
|
196
|
+
// controlled and thus no barrier).
|
|
197
|
+
return detail("writes a dynamic-workflow .dwf.ts script auto-loaded by future sessions (arbitrary-code surface)");
|
|
198
|
+
case "workflow-delete":
|
|
199
|
+
// The target's tier is resolved from the named workflow at delete time
|
|
200
|
+
// (not from params) and includes the user tier — fail closed for workers.
|
|
201
|
+
return detail("deletes a persistent dynamic-workflow file (user tier included)");
|
|
202
|
+
case "import":
|
|
203
|
+
if (scope === "user") return detail("imports a run bundle into user-scope state (all future sessions)");
|
|
204
|
+
return undefined;
|
|
205
|
+
case "export":
|
|
206
|
+
if (scope === "user") return detail("writes a user-scope run export (all future sessions)");
|
|
207
|
+
return undefined;
|
|
174
208
|
default:
|
|
175
209
|
return undefined;
|
|
176
210
|
}
|
|
@@ -186,6 +220,9 @@ export interface RootGateParamsLike {
|
|
|
186
220
|
subAction?: unknown;
|
|
187
221
|
/** Advisory-only dryRun preview flag. */
|
|
188
222
|
dryRun?: unknown;
|
|
223
|
+
/** Destructive-gate confirm flag — caller-controlled, so the root-gate
|
|
224
|
+
* deliberately does NOT treat it as a bypass (R11-S1 pin). */
|
|
225
|
+
confirm?: unknown;
|
|
189
226
|
config?: unknown;
|
|
190
227
|
}
|
|
191
228
|
|
|
@@ -193,15 +230,29 @@ function configRecord(config: unknown): Record<string, unknown> {
|
|
|
193
230
|
return config && typeof config === "object" && !Array.isArray(config) ? (config as Record<string, unknown>) : {};
|
|
194
231
|
}
|
|
195
232
|
|
|
196
|
-
|
|
233
|
+
/**
|
|
234
|
+
* R11-F1 (fix-wave 6, M1-2): the canonical scope carrier is `config.scope` —
|
|
235
|
+
* the same key every scope-bearing handler reads (create/settings/config in
|
|
236
|
+
* their dispatch paths, import and workflow-create in their handlers). The
|
|
237
|
+
* previous `params.scope ?? config.scope` read let a divergent
|
|
238
|
+
* `{scope: "project", config: {scope: "user"}}` payload pass the gate while
|
|
239
|
+
* the handler wrote the user tier. Handlers ignore top-level `scope`, so the
|
|
240
|
+
* gate must too. Per-action defaults mirror each handler's own default when
|
|
241
|
+
* the carrier is absent: import/export default `project` (handleImport),
|
|
242
|
+
* everything else defaults `user` (fail-closed, like create/settings/config).
|
|
243
|
+
*/
|
|
244
|
+
function resolveScopeParam(params: RootGateParamsLike, action?: string): RootGateScope {
|
|
197
245
|
const fromConfig = configRecord(params.config).scope;
|
|
198
|
-
|
|
199
|
-
|
|
246
|
+
if (fromConfig === "project") return "project";
|
|
247
|
+
if (fromConfig === "user") return "user";
|
|
248
|
+
return action === "import" || action === "export" ? "project" : "user";
|
|
200
249
|
}
|
|
201
250
|
|
|
202
251
|
/**
|
|
203
252
|
* Facade resolver: derive (subcommand, scope) from raw team-tool params for
|
|
204
253
|
* the gated actions and decide whether a NON-ROOT session must be blocked.
|
|
254
|
+
* R11-F1: scope resolves from `config.scope` only (canonical carrier — see
|
|
255
|
+
* {@link resolveScopeParam}); top-level `scope` never influences the gate.
|
|
205
256
|
* `update` is special: its effective write scope is the matched resource's
|
|
206
257
|
* scope, unknown until `resolveMutable` — the facade blocks explicit
|
|
207
258
|
* `scope:"user"` and management.ts gates the resolved case.
|
|
@@ -216,7 +267,7 @@ export function rootGateBlockReason(params: RootGateParamsLike, env: NodeJS.Proc
|
|
|
216
267
|
(typeof params.subAction === "string" ? params.subAction : undefined) ??
|
|
217
268
|
(typeof cfg.subAction === "string" ? cfg.subAction : undefined);
|
|
218
269
|
const subcommand = typeof rawSub === "string" ? rawSub.toLowerCase() : undefined;
|
|
219
|
-
const scope = resolveScopeParam(params);
|
|
270
|
+
const scope = resolveScopeParam(params, action);
|
|
220
271
|
const force = params.force === true || cfg.force === true;
|
|
221
272
|
|
|
222
273
|
// Reads / previews never gated (destructive-gate dryRun convention).
|
|
@@ -233,18 +284,27 @@ export function rootGateBlockReason(params: RootGateParamsLike, env: NodeJS.Proc
|
|
|
233
284
|
if (action === "schedule") {
|
|
234
285
|
return assertRootOnlyAction("schedule", subcommand, scope, env)?.message;
|
|
235
286
|
}
|
|
236
|
-
// config: gate only the WRITE path
|
|
287
|
+
// config: gate only the WRITE path. Carrier keys (scope/unset/subAction/
|
|
288
|
+
// dryRun/confirm/force/args) are dropped by the config schema
|
|
289
|
+
// (configPatchFromConfig → parseConfig), so a payload of ONLY carrier keys
|
|
290
|
+
// can never change the file — a read. Unknown junk keys stay
|
|
291
|
+
// write-classified (fail-closed). `args` was missing from this list and
|
|
292
|
+
// miscounted `config {args:"show"}` reads as writes (RT24-F1, live-caught
|
|
293
|
+
// 2026-10-18). Note parseConfig injects defaults (runtime.*), so
|
|
294
|
+
// patch-emptiness cannot be used here — see updateConfig skip-if-unchanged.
|
|
237
295
|
if (action === "config") {
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
);
|
|
296
|
+
const carrierKeys = new Set(["scope", "unset", "subAction", "dryRun", "confirm", "force", "args"]);
|
|
297
|
+
const patchKeys = Object.keys(cfg).filter((k) => !carrierKeys.has(k) && cfg[k] !== undefined);
|
|
241
298
|
const unset = Array.isArray(cfg.unset) ? cfg.unset.length > 0 : cfg.unset !== undefined;
|
|
242
299
|
const isWrite = patchKeys.length > 0 || unset;
|
|
243
300
|
if (!isWrite) return undefined;
|
|
244
301
|
}
|
|
245
|
-
// autonomy: gate only the WRITE path (patch present).
|
|
302
|
+
// autonomy: gate only the WRITE path (patch keys present). Same carrier
|
|
303
|
+
// reasoning as config — `args`/`subAction` are subcommand carriers, not
|
|
304
|
+
// autonomous-config fields.
|
|
246
305
|
if (action === "autonomy") {
|
|
247
|
-
const
|
|
306
|
+
const carrierKeys = new Set(["subAction", "dryRun", "confirm", "args"]);
|
|
307
|
+
const patchKeys = Object.keys(cfg).filter((k) => !carrierKeys.has(k) && cfg[k] !== undefined);
|
|
248
308
|
if (patchKeys.length === 0) return undefined;
|
|
249
309
|
}
|
|
250
310
|
// settings: derive the subcommand from `config.args` ("set k v", "unset k",
|
|
@@ -86,7 +86,15 @@ export async function handleManageDomain(params: TeamToolParamsValue, ctx: TeamC
|
|
|
86
86
|
...(initialized.skippedFiles.length
|
|
87
87
|
? ["Skipped existing files:", ...initialized.skippedFiles.map((file) => `- ${file}`)]
|
|
88
88
|
: []),
|
|
89
|
-
`Config: ${initialized.configPath || "(none)"} (${initialized.configScope}${
|
|
89
|
+
`Config: ${initialized.configPath || "(none)"} (${initialized.configScope}${
|
|
90
|
+
initialized.configBackupPath
|
|
91
|
+
? "; overwritten — previous content backed up to " + initialized.configBackupPath
|
|
92
|
+
: initialized.configCreated
|
|
93
|
+
? "; created"
|
|
94
|
+
: initialized.configSkipped
|
|
95
|
+
? "; already existed — left untouched (pass config.overwrite: true to replace)"
|
|
96
|
+
: "; unchanged"
|
|
97
|
+
})`,
|
|
90
98
|
`Ignore: ${initialized.gitignorePath} (${initialized.gitignoreUpdated ? "updated" : "already configured"})`,
|
|
91
99
|
].join("\n"),
|
|
92
100
|
{ action: "init", status: "ok" },
|
|
@@ -89,6 +89,26 @@ export function getScheduledJobs(
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* F12-M3: hard cap on scheduled jobs. `add` had no count limit — every job also
|
|
94
|
+
* creates a 1:1 scheduler timer and is persisted, so a project-scope caller could
|
|
95
|
+
* grow the store (and the in-memory timer map) without bound. 100 is well above
|
|
96
|
+
* any hand-authored schedule list and cheap to hold.
|
|
97
|
+
*/
|
|
98
|
+
export const MAX_SCHEDULED_JOBS = 100;
|
|
99
|
+
|
|
100
|
+
/** @internal — count of persisted scheduled jobs for this cwd (R4-M-B4-adjacent: the
|
|
101
|
+
* cap bounds the on-disk store + the 1:1 scheduler timer map, so it counts BOTH
|
|
102
|
+
* tiers rather than the B2-gated registration view). */
|
|
103
|
+
function scheduledJobCount(cwd: string): number {
|
|
104
|
+
try {
|
|
105
|
+
const tiers = loadCrewSettingsTiers(cwd, undefined);
|
|
106
|
+
return [...(tiers.user.scheduledJobs ?? []), ...(tiers.project.scheduledJobs ?? [])].filter(isScheduledJobLike).length;
|
|
107
|
+
} catch {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
/** Shape guard for the settings fallback — mirrors settings-store's private
|
|
93
113
|
* `validateScheduledJob` (id + scheduleType + enabled) so invalid persisted
|
|
94
114
|
* entries are skipped instead of reaching renderers. */
|
|
@@ -219,6 +239,17 @@ export function handleSchedule(params: TeamToolParamsValue, ctx: TeamContext): P
|
|
|
219
239
|
return result(msg, { action: "schedule", status: "error" }, true);
|
|
220
240
|
}
|
|
221
241
|
|
|
242
|
+
// F12-M3: enforce the cap BEFORE building/persisting the job so the caller gets
|
|
243
|
+
// a clear refusal instead of a silently-dropped write (every add also creates a
|
|
244
|
+
// 1:1 scheduler timer — an unbounded job list is an unbounded timer set).
|
|
245
|
+
if (scheduledJobCount(ctx.cwd) >= MAX_SCHEDULED_JOBS) {
|
|
246
|
+
return result(
|
|
247
|
+
`Scheduled job limit reached (${MAX_SCHEDULED_JOBS}). Remove finished schedules first: { action: 'schedule', subAction: 'list' } then subAction: 'remove', id: '<jobId>'.`,
|
|
248
|
+
{ action: "schedule", status: "error" },
|
|
249
|
+
true,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
222
253
|
const { spec, schedule, scheduleType, intervalMs } = specResult;
|
|
223
254
|
const next = nextRunTime(spec);
|
|
224
255
|
if ("error" in next) return result(next.error, { action: "schedule", status: "error" }, true);
|
|
@@ -301,10 +332,13 @@ function scheduledJobsOf(settings: CrewSettings): import("../../runtime/scheduli
|
|
|
301
332
|
|
|
302
333
|
function persistScheduledJob(cwd: string, job: import("../../runtime/scheduling/scheduler.ts").ScheduledJob): void {
|
|
303
334
|
try {
|
|
304
|
-
updateCrewSettings(cwd, (settings) =>
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
335
|
+
updateCrewSettings(cwd, (settings) => {
|
|
336
|
+
const existing = scheduledJobsOf(settings);
|
|
337
|
+
// F12-M3: enforced here as well as at the tool boundary so no other writer
|
|
338
|
+
// can push past the cap into the persisted store.
|
|
339
|
+
if (existing.length >= MAX_SCHEDULED_JOBS) return settings;
|
|
340
|
+
return { ...settings, scheduledJobs: [...existing, job] };
|
|
341
|
+
});
|
|
308
342
|
} catch {
|
|
309
343
|
/* best-effort persistence */
|
|
310
344
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadConfig, updateConfig } from "../../config/config.ts";
|
|
2
|
+
import { isDangerousPath } from "../../config/config-validation.ts";
|
|
2
3
|
import { suggestConfigKey } from "../../config/suggestions.ts";
|
|
3
4
|
import { collectSensitiveConfigPaths } from "../../schema/sensitive-config-paths.ts";
|
|
4
5
|
import { discoverPiThemes, formatThemesListing, setPiTheme } from "../../ui/theme-discovery.ts";
|
|
@@ -60,7 +61,11 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
|
|
|
60
61
|
"reliability.ambientStatusInjection": true,
|
|
61
62
|
"reliability.forcePreflight": false,
|
|
62
63
|
"reliability.scopeModels": false,
|
|
63
|
-
|
|
64
|
+
// R45-F3: the real default is ON — observability installs the metric sink when
|
|
65
|
+
// `config.telemetry?.enabled !== false` (registration/observability.ts:109), so
|
|
66
|
+
// an unset key means enabled. This map only drives the "(default)" display;
|
|
67
|
+
// `false` here printed a wrong default for `team-settings get telemetry.enabled`.
|
|
68
|
+
"telemetry.enabled": true,
|
|
64
69
|
"notifications.enabled": false,
|
|
65
70
|
};
|
|
66
71
|
|
|
@@ -69,6 +74,12 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
|
|
|
69
74
|
// ---------------------------------------------------------------------------
|
|
70
75
|
|
|
71
76
|
function setNested(obj: Record<string, unknown>, path: string, value: unknown): void {
|
|
77
|
+
// R24-H1 (M1-1): walking a raw dotted key through `target["__proto__"]`
|
|
78
|
+
// resolves to Object.prototype via the [[Prototype]] getter — the write
|
|
79
|
+
// then lands on Object.prototype BEFORE configPatchFromConfig sanitizes.
|
|
80
|
+
// Refuse dangerous segments at the walker (shared proto-safe helpers in
|
|
81
|
+
// config-validation.ts).
|
|
82
|
+
if (isDangerousPath(path)) return;
|
|
72
83
|
const keys = path.split(".");
|
|
73
84
|
let target: Record<string, unknown> = obj;
|
|
74
85
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
@@ -81,10 +92,17 @@ function setNested(obj: Record<string, unknown>, path: string, value: unknown):
|
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
function getNested(obj: Record<string, unknown>, path: string): unknown {
|
|
95
|
+
// R45-S1 read-side (wave-6 M4-R2): mirror the write path's guard. A raw
|
|
96
|
+
// dotted key resolves INHERITED members without an own-property check —
|
|
97
|
+
// `get __proto__.toString` / `get constructor` walk Object.prototype and the
|
|
98
|
+
// value is printed by formatValue (function source) and returned in the typed
|
|
99
|
+
// result. Refuse any hop the object does not OWN. (The write path already
|
|
100
|
+
// rejects dangerous segments via isDangerousPath in setNested.)
|
|
84
101
|
const keys = path.split(".");
|
|
85
102
|
let current: unknown = obj;
|
|
86
103
|
for (const key of keys) {
|
|
87
104
|
if (!current || typeof current !== "object") return undefined;
|
|
105
|
+
if (!Object.hasOwn(current, key)) return undefined;
|
|
88
106
|
current = (current as Record<string, unknown>)[key];
|
|
89
107
|
}
|
|
90
108
|
return current;
|
|
@@ -92,7 +110,12 @@ function getNested(obj: Record<string, unknown>, path: string): unknown {
|
|
|
92
110
|
|
|
93
111
|
function formatValue(value: unknown, key?: string): string {
|
|
94
112
|
if (value === undefined || value === null) {
|
|
95
|
-
|
|
113
|
+
// R45-S1 read-side (wave-6 M4-R2): `EFFECTIVE_DEFAULTS` is a plain object
|
|
114
|
+
// literal, so an unguarded `EFFECTIVE_DEFAULTS["constructor"]` resolves the
|
|
115
|
+
// INHERITED Object constructor and prints its source — the same
|
|
116
|
+
// inherited-member leak as the getNested walker (pinned by
|
|
117
|
+
// `get constructor`). Require an OWN entry.
|
|
118
|
+
const def = key && Object.hasOwn(EFFECTIVE_DEFAULTS, key) ? EFFECTIVE_DEFAULTS[key] : undefined;
|
|
96
119
|
if (def !== undefined) return `${String(def)} (default)`;
|
|
97
120
|
return "<not set>";
|
|
98
121
|
}
|
|
@@ -145,6 +168,43 @@ function flattenConfig(obj: unknown, prefix: string = ""): string[] {
|
|
|
145
168
|
return lines;
|
|
146
169
|
}
|
|
147
170
|
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Sensitive-value redaction (US-030 / R45-S1)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/** Marker printed in place of a schema-marked sensitive value. */
|
|
176
|
+
const SENSITIVE_REDACTION_MARKER = "***(redacted — sensitive)";
|
|
177
|
+
|
|
178
|
+
/** Schema-marked sensitive dotted paths — computed once from the same source
|
|
179
|
+
* the `get` path historically used, so `get`/`list`/`json` can never drift. */
|
|
180
|
+
const SENSITIVE_CONFIG_PATHS = collectSensitiveConfigPaths();
|
|
181
|
+
|
|
182
|
+
function isSensitiveConfigPath(dotted: string): boolean {
|
|
183
|
+
return SENSITIVE_CONFIG_PATHS.some((p) => dotted === p || dotted.startsWith(`${p}.`));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* R45-S1 (deep-review 2026-10-19): `list` and `json` dump the WHOLE effective
|
|
188
|
+
* config, so without redaction they bypass the `get` path's US-030 redaction
|
|
189
|
+
* and echo sensitive values (webhook.secret, otlp.headers, …) verbatim into
|
|
190
|
+
* the session transcript — any in-session agent can read tool output. Returns
|
|
191
|
+
* a deep clone with every sensitive-marked value replaced by the marker;
|
|
192
|
+
* undefined/null are kept as-is (they leak nothing and stay invisible in both
|
|
193
|
+
* output formats).
|
|
194
|
+
*/
|
|
195
|
+
function redactSensitiveConfig(value: unknown, prefix: string = ""): unknown {
|
|
196
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
197
|
+
const out: Record<string, unknown> = {};
|
|
198
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
199
|
+
const dotted = prefix ? `${prefix}.${key}` : key;
|
|
200
|
+
out[key] =
|
|
201
|
+
isSensitiveConfigPath(dotted) && child !== undefined && child !== null
|
|
202
|
+
? SENSITIVE_REDACTION_MARKER
|
|
203
|
+
: redactSensitiveConfig(child, dotted);
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
148
208
|
// ---------------------------------------------------------------------------
|
|
149
209
|
// Known config keys — mirrors config-schema.ts + config.ts.
|
|
150
210
|
// ---------------------------------------------------------------------------
|
|
@@ -318,7 +378,8 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
318
378
|
// team-settings list — show ALL effective values (not just KNOWN_KEYS)
|
|
319
379
|
if (!args || args === "list") {
|
|
320
380
|
const lines = ["pi-crew effective settings:", `Config file: ${loaded.path}`, ""];
|
|
321
|
-
|
|
381
|
+
// R45-S1: full dump must pass through the same redaction as `get`.
|
|
382
|
+
const flatLines = flattenConfig(redactSensitiveConfig(effective));
|
|
322
383
|
if (flatLines.length > 0) {
|
|
323
384
|
lines.push(...flatLines);
|
|
324
385
|
} else {
|
|
@@ -344,7 +405,8 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
344
405
|
`// Sources: ${loaded.paths?.join(", ") ?? loaded.path}`,
|
|
345
406
|
...(loaded.warnings?.map((w) => `// WARNING: ${w}`) ?? []),
|
|
346
407
|
"",
|
|
347
|
-
|
|
408
|
+
// R45-S1: full dump must pass through the same redaction as `get`.
|
|
409
|
+
JSON.stringify(redactSensitiveConfig(effective), null, 2),
|
|
348
410
|
];
|
|
349
411
|
return result(lines.join("\n"), { ...OK } as never);
|
|
350
412
|
}
|
|
@@ -475,11 +537,11 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
475
537
|
// US-030 security review: a value whose dotted path is schema-marked
|
|
476
538
|
// sensitive (webhook.secret, otlp.headers, …) must not be echoed into
|
|
477
539
|
// the session transcript — any in-session agent can read tool output.
|
|
478
|
-
const sensitive =
|
|
540
|
+
const sensitive = isSensitiveConfigPath(key);
|
|
479
541
|
const rendered = sensitive
|
|
480
542
|
? value === undefined || value === null
|
|
481
543
|
? "<not set>"
|
|
482
|
-
:
|
|
544
|
+
: SENSITIVE_REDACTION_MARKER
|
|
483
545
|
: formatValue(value, key);
|
|
484
546
|
return result(`${key} = ${rendered}${note}`, {
|
|
485
547
|
...OK,
|
|
@@ -492,6 +554,10 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
492
554
|
if (args.startsWith("unset ")) {
|
|
493
555
|
const key = args.slice(6).trim();
|
|
494
556
|
if (!key) return result("Usage: team-settings unset <key>", { ...ERR }, true);
|
|
557
|
+
// R24-H2 (M1-1): refuse reserved Object.prototype key paths at the
|
|
558
|
+
// boundary — `unset __proto__.toString` would otherwise delete
|
|
559
|
+
// Object.prototype.toString via the [[Prototype]] getter walk.
|
|
560
|
+
if (isDangerousPath(key)) return result(`Refusing to unset '${key}': reserved Object.prototype key path`, { ...ERR }, true);
|
|
495
561
|
try {
|
|
496
562
|
const saved = updateConfig({}, { cwd: ctx.cwd, scope, unsetPaths: [key] });
|
|
497
563
|
return result(`Unset ${key}\nSaved to: ${saved.path}`, {
|
|
@@ -516,6 +582,10 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
516
582
|
const key = rest.slice(0, spaceIdx);
|
|
517
583
|
const rawValue = rest.slice(spaceIdx + 1).trim();
|
|
518
584
|
if (!key) return result("Usage: team-settings set <key> <value>", { ...ERR }, true);
|
|
585
|
+
// R24-H1 (M1-1): refuse reserved Object.prototype key paths at the
|
|
586
|
+
// boundary — `set __proto__.polluted 1` would otherwise pollute
|
|
587
|
+
// Object.prototype via the [[Prototype]] getter walk in setNested.
|
|
588
|
+
if (isDangerousPath(key)) return result(`Refusing to set '${key}': reserved Object.prototype key path`, { ...ERR }, true);
|
|
519
589
|
|
|
520
590
|
const value = parseValue(rawValue);
|
|
521
591
|
const patch: Record<string, unknown> = {};
|
|
@@ -27,7 +27,7 @@ interface CorruptEntry {
|
|
|
27
27
|
reason: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
interface StuckTask {
|
|
30
|
+
export interface StuckTask {
|
|
31
31
|
runId: string;
|
|
32
32
|
taskId: string;
|
|
33
33
|
staleMs: number;
|
|
@@ -80,6 +80,18 @@ function readRunTasks(stateRoot: string): TeamTaskState[] {
|
|
|
80
80
|
*/
|
|
81
81
|
export function detectStuckTasks(run: { runId: string; stateRoot: string }, now: number): StuckTask[] {
|
|
82
82
|
const tasks = readRunTasks(run.stateRoot);
|
|
83
|
+
return stuckTasksFromRecords(tasks, run.runId, now);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* R48-N1 (2026-10-19): pure stuck-task detection over an ALREADY-loaded task
|
|
88
|
+
* array. Extracted from {@link detectStuckTasks} so the status surface — which
|
|
89
|
+
* already holds `tasks` in memory — can consult the SAME external heartbeat-age
|
|
90
|
+
* signal without a second disk read. This is the EXTERNAL (timestamp-based)
|
|
91
|
+
* signal the status `needsAttention` surface previously never had: it does not
|
|
92
|
+
* trust the worker's self-reported `activityState`.
|
|
93
|
+
*/
|
|
94
|
+
export function stuckTasksFromRecords(tasks: TeamTaskState[], runId: string, now: number): StuckTask[] {
|
|
83
95
|
const stuck: StuckTask[] = [];
|
|
84
96
|
|
|
85
97
|
for (const task of tasks) {
|
|
@@ -103,7 +115,7 @@ export function detectStuckTasks(run: { runId: string; stateRoot: string }, now:
|
|
|
103
115
|
const staleMs = now - latest;
|
|
104
116
|
if (staleMs > STUCK_TASK_THRESHOLD_MS) {
|
|
105
117
|
stuck.push({
|
|
106
|
-
runId
|
|
118
|
+
runId,
|
|
107
119
|
taskId: task.id,
|
|
108
120
|
staleMs,
|
|
109
121
|
detail: `stale ${Math.round(staleMs / 60_000)}m (last activity: ${new Date(latest).toISOString()})`,
|