gentle-pi 3.6.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +95 -30
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +117 -235
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +509 -275
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +105 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/foreign-target-grants.ts +32 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +53 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-change-capture.ts +10 -1
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +53 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +400 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/foreign-target-grants.test.ts +58 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1411 -256
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +13 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-change-capture.test.ts +12 -1
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Recognizable executable forms only. This is deliberately not a shell/SQL
|
|
2
|
+
// interpreter or a sandbox: no expansion, script loading or remote inspection.
|
|
3
|
+
const DESTRUCTION_KIND = { DATABASE: "database", FILESYSTEM: "filesystem", GIT: "git" } as const;
|
|
4
|
+
type DestructionKind = (typeof DESTRUCTION_KIND)[keyof typeof DESTRUCTION_KIND];
|
|
5
|
+
export interface DestructiveCommandMatch {
|
|
6
|
+
kind: DestructionKind;
|
|
7
|
+
triggerIndex: number;
|
|
8
|
+
hardDeny: boolean;
|
|
9
|
+
}
|
|
10
|
+
interface Token {
|
|
11
|
+
value: string;
|
|
12
|
+
raw: string;
|
|
13
|
+
operator: boolean;
|
|
14
|
+
index: number;
|
|
15
|
+
}
|
|
16
|
+
const separators = new Set([";", "&&", "||", "|", "&", "\n", "(", ")"]);
|
|
17
|
+
|
|
18
|
+
/** Keep quoted arguments intact so harmless prose is never treated as a command. */
|
|
19
|
+
function tokenize(command: string): Token[] {
|
|
20
|
+
const tokens: Token[] = [];
|
|
21
|
+
// Literal, named heredocs are data, not executable shell segments.
|
|
22
|
+
command = command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g, (raw) => {
|
|
23
|
+
const headerLength = raw.indexOf("\n") + 1;
|
|
24
|
+
return raw.slice(0, headerLength) + " ".repeat(raw.length - headerLength);
|
|
25
|
+
});
|
|
26
|
+
const pattern = /(?:"(?:\\.|[^"\\])*"|'[^']*'|\\.|[^\s;&|()'"\\])+|&&|\|\||[;&|()\n]/g;
|
|
27
|
+
for (const match of command.matchAll(pattern)) {
|
|
28
|
+
const raw = match[0];
|
|
29
|
+
const value = raw.replace(/"((?:\\.|[^"\\])*)"|'([^']*)'|\\(.)/g,
|
|
30
|
+
(_raw, double: string | undefined, single: string | undefined, escaped: string | undefined) => {
|
|
31
|
+
if (double !== undefined) return double.replace(/\\(["\\$`\n])/g, (_escape, char: string) => char === "\n" ? "" : char);
|
|
32
|
+
return single ?? escaped ?? "";
|
|
33
|
+
});
|
|
34
|
+
// Only raw operators are boundaries; quoted/escaped operators are words.
|
|
35
|
+
tokens.push({ value, raw, operator: separators.has(raw), index: match.index });
|
|
36
|
+
}
|
|
37
|
+
return tokens;
|
|
38
|
+
}
|
|
39
|
+
const executable = (value: string) => value.split("/").at(-1) ?? value;
|
|
40
|
+
|
|
41
|
+
function sqlDestroys(sql: string): boolean {
|
|
42
|
+
// Mask quoted identifiers as non-keyword atoms, and remove comments and
|
|
43
|
+
// strings. A table named "where" is not a predicate or a statement boundary.
|
|
44
|
+
const code = sql.replace(/--[^\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|"(?:""|[^"])*"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\]/g,
|
|
45
|
+
(raw) => /^["`\[]/.test(raw) ? "identifier" : " ");
|
|
46
|
+
return code.split(";").some((statement) => {
|
|
47
|
+
if (/^\s*(?:DROP\s+(?:TABLE|DATABASE|SCHEMA|INDEX|VIEW)\b|TRUNCATE\s+(?:TABLE\s+)?\S)/i.test(statement)) return true;
|
|
48
|
+
if (!/^\s*DELETE\s+FROM\s+\S/i.test(statement)) return false;
|
|
49
|
+
return !/\bWHERE\b/i.test(statement) || /\bWHERE\s+(?:1\s*=\s*1|TRUE)\s*$/i.test(statement.trim());
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function recognizeDestructiveCommands(command: string, depth = 0): DestructiveCommandMatch[] {
|
|
54
|
+
if (depth > 4) return [];
|
|
55
|
+
const tokens = tokenize(command);
|
|
56
|
+
const matches: DestructiveCommandMatch[] = [];
|
|
57
|
+
let previous: Token[] = [];
|
|
58
|
+
let previousSeparator = "";
|
|
59
|
+
let start = 0;
|
|
60
|
+
for (let end = 0; end <= tokens.length; end++) {
|
|
61
|
+
if (end < tokens.length && !tokens[end].operator) continue;
|
|
62
|
+
const segment = tokens.slice(start, end);
|
|
63
|
+
let cursor = 0;
|
|
64
|
+
// Assignments and familiar executable wrappers preserve command position.
|
|
65
|
+
while (cursor < segment.length) {
|
|
66
|
+
const value = segment[cursor].value;
|
|
67
|
+
if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(value)) {
|
|
68
|
+
cursor++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const name = executable(value);
|
|
72
|
+
if (!["env", "sudo", "command", "exec", "nohup", "timeout", "xargs"].includes(name)) break;
|
|
73
|
+
const valueOptions: Record<string, readonly string[]> = {
|
|
74
|
+
env: ["-u", "--unset", "-C", "--chdir"],
|
|
75
|
+
sudo: ["-u", "-g", "-h", "-p", "-C", "--user", "--group", "--host", "--prompt", "--chdir"],
|
|
76
|
+
timeout: ["-s", "--signal", "-k", "--kill-after"],
|
|
77
|
+
xargs: ["-n", "-I", "-P", "--max-args", "--replace", "--max-procs"],
|
|
78
|
+
};
|
|
79
|
+
cursor++;
|
|
80
|
+
while (cursor < segment.length && segment[cursor].value.startsWith("-")) {
|
|
81
|
+
const flag = segment[cursor++].value;
|
|
82
|
+
if (flag === "--") break;
|
|
83
|
+
if (valueOptions[name]?.includes(flag)) cursor++;
|
|
84
|
+
}
|
|
85
|
+
if (name === "timeout") cursor++;
|
|
86
|
+
}
|
|
87
|
+
const head = segment[cursor];
|
|
88
|
+
const name = executable(head?.value ?? "");
|
|
89
|
+
const args = segment.slice(cursor + 1).map((token) => token.value);
|
|
90
|
+
const add = (kind: DestructionKind, hardDeny = false) => matches.push({ kind, hardDeny, triggerIndex: head.index });
|
|
91
|
+
if (["sh", "bash", "zsh", "dash"].includes(name)) {
|
|
92
|
+
const flag = args.findIndex((arg) => /^-[a-z]*c[a-z]*$/.test(arg));
|
|
93
|
+
if (flag >= 0 && args[flag + 1]) {
|
|
94
|
+
// Nested offsets use the enclosing executable, which is stable even
|
|
95
|
+
// when quote removal changes the payload's byte positions.
|
|
96
|
+
matches.push(...recognizeDestructiveCommands(args[flag + 1], depth + 1).map((match) => ({ ...match, triggerIndex: head.index })));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (["psql", "mysql", "mariadb", "sqlite3"].includes(name)) {
|
|
100
|
+
const payloads = args.map((arg) => arg.replace(/^(?:--command|--execute)=/, ""));
|
|
101
|
+
const heredoc = command.slice(head.index).match(/^[^\n]*<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/);
|
|
102
|
+
if (heredoc) payloads.push(heredoc[3]);
|
|
103
|
+
if (previousSeparator === "|" && ["echo", "printf"].includes(executable(previous[0]?.value ?? ""))) {
|
|
104
|
+
payloads.push(previous.slice(1).map((token) => token.value).join(" ").replace(/^%s\s*/, ""));
|
|
105
|
+
}
|
|
106
|
+
if (payloads.some(sqlDestroys)) add(DESTRUCTION_KIND.DATABASE);
|
|
107
|
+
}
|
|
108
|
+
if (name === "rm" && args.some((arg) => /^-[^-]*[rR]/.test(arg) || arg === "--recursive")) {
|
|
109
|
+
const rootTarget = args.some((arg) => /^(?:\/(?:\*+)?$|\.{1,2}\/?$|~(?:\/|$)|\$\{?HOME\}?(?:\/|$))/.test(arg));
|
|
110
|
+
add(DESTRUCTION_KIND.FILESYSTEM, rootTarget);
|
|
111
|
+
}
|
|
112
|
+
if (name === "find") {
|
|
113
|
+
if (args.includes("-delete")) add(DESTRUCTION_KIND.FILESYSTEM);
|
|
114
|
+
for (let execIndex = 0; execIndex < args.length; execIndex++) {
|
|
115
|
+
if (!["-exec", "-execdir"].includes(args[execIndex])) continue;
|
|
116
|
+
const invocation = segment.slice(cursor + execIndex + 2);
|
|
117
|
+
const terminator = invocation.findIndex((arg) => arg.value === ";" || arg.value === "+");
|
|
118
|
+
const nested = (terminator < 0 ? invocation : invocation.slice(0, terminator)).map((arg) => arg.raw).join(" ");
|
|
119
|
+
matches.push(...recognizeDestructiveCommands(nested, depth + 1).map((match) => ({ ...match, triggerIndex: head.index })));
|
|
120
|
+
if (terminator < 0) break;
|
|
121
|
+
execIndex += terminator + 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (name === "git") {
|
|
125
|
+
let index = 0;
|
|
126
|
+
while (args[index]?.startsWith("-")) {
|
|
127
|
+
const flag = args[index++];
|
|
128
|
+
if (["-C", "-c", "--git-dir", "--work-tree", "--namespace"].includes(flag)) index++;
|
|
129
|
+
}
|
|
130
|
+
const action = args[index];
|
|
131
|
+
const flags = args.slice(index + 1);
|
|
132
|
+
const force = flags.some((arg) => /^--force(?:-with-lease|=|$)/.test(arg) || /^-[^-]*f/.test(arg));
|
|
133
|
+
const hard = (action === "reset" && flags.includes("--hard")) || (action === "clean" && force) || (action === "push" && force);
|
|
134
|
+
const branchDelete = action === "branch" && (flags.some((arg) => /^-[^-]*D/.test(arg)) || (force && flags.some((arg) => arg === "--delete" || /^-[^-]*d/.test(arg))));
|
|
135
|
+
if (hard || branchDelete || ["reset", "clean", "restore", "rebase"].includes(action) ||
|
|
136
|
+
(action === "checkout" && (force || flags.includes("--"))) ||
|
|
137
|
+
(action === "stash" && ["drop", "clear"].includes(flags[0]))) add(DESTRUCTION_KIND.GIT, hard);
|
|
138
|
+
}
|
|
139
|
+
previous = segment;
|
|
140
|
+
previousSeparator = tokens[end]?.value ?? "";
|
|
141
|
+
start = end + 1;
|
|
142
|
+
}
|
|
143
|
+
return matches;
|
|
144
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { WorktreeIdentity } from "./session-worktree-registry.ts";
|
|
3
|
+
|
|
4
|
+
// Deliberately ephemeral: no session entries or disk persistence can restore consent.
|
|
5
|
+
export class ForeignTargetGrants {
|
|
6
|
+
private readonly grants = new Map<string, Set<string>>();
|
|
7
|
+
private manager?: ExtensionContext["sessionManager"];
|
|
8
|
+
private sessionId?: string;
|
|
9
|
+
|
|
10
|
+
async authorize(ctx: Pick<ExtensionContext, "sessionManager" | "hasUI" | "ui">, target: WorktreeIdentity, options: { continuation?: boolean; signal?: AbortSignal } = {}): Promise<void> {
|
|
11
|
+
if (options.signal?.aborted) throw new Error("Foreign clone authorization aborted.");
|
|
12
|
+
const id = ctx.sessionManager.getSessionId();
|
|
13
|
+
if (!id) throw new Error("Foreign target requires an active session identity.");
|
|
14
|
+
if (this.manager !== ctx.sessionManager || this.sessionId !== id) {
|
|
15
|
+
this.grants.clear();
|
|
16
|
+
this.manager = ctx.sessionManager;
|
|
17
|
+
this.sessionId = id;
|
|
18
|
+
}
|
|
19
|
+
if (this.grants.get(id)?.has(target.commonDir)) return;
|
|
20
|
+
if (options.continuation) throw new Error("Foreign clone continuation grant was lost; start a new explicit launch.");
|
|
21
|
+
if (!ctx.hasUI || !ctx.ui?.confirm || await ctx.ui.confirm("Authorize foreign clone subagent", `Launch a subagent in ${target.root}? Git common directory: ${target.commonDir}. This grant applies only to this live session and clone.`) !== true) throw new Error("Foreign clone requires interactive human consent before launch.");
|
|
22
|
+
if (options.signal?.aborted) throw new Error("Foreign clone authorization aborted.");
|
|
23
|
+
if (ctx.sessionManager !== this.manager || ctx.sessionManager.getSessionId() !== id) throw new Error("Session identity changed during foreign clone authorization.");
|
|
24
|
+
const grants = this.grants.get(id) ?? new Set<string>();
|
|
25
|
+
grants.add(target.commonDir);
|
|
26
|
+
this.grants.set(id, grants);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
assertCurrent(ctx: Pick<ExtensionContext, "sessionManager">, target: WorktreeIdentity): void {
|
|
30
|
+
if (ctx.sessionManager !== this.manager || !this.sessionId || ctx.sessionManager.getSessionId() !== this.sessionId || !this.grants.get(this.sessionId)?.has(target.commonDir)) throw new Error("Foreign clone grant is no longer live.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const GENTLE_AI_TIMING_ENTRY = "gentle-ai-elapsed-timing/v1";
|
|
2
|
+
|
|
3
|
+
export interface GentleAiTimingRecord {
|
|
4
|
+
toolCallId: string;
|
|
5
|
+
startedAt: number;
|
|
6
|
+
endedAt?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** What the renderer consumes: a read-only lookup keyed by pi's stable toolCallId. */
|
|
10
|
+
export interface GentleAiTimingLookup {
|
|
11
|
+
lookup(toolCallId: string): GentleAiTimingRecord | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GentleAiTimingEntry {
|
|
15
|
+
type: string;
|
|
16
|
+
customType?: string;
|
|
17
|
+
data?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GentleAiTimingSessionReader {
|
|
21
|
+
getEntries(): readonly GentleAiTimingEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GentleAiTimingAppendHost {
|
|
25
|
+
appendEntry(type: string, data?: unknown): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseGentleAiTimingData(data: unknown): GentleAiTimingRecord | undefined {
|
|
29
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined;
|
|
30
|
+
const record = data as { toolCallId?: unknown; startedAt?: unknown; endedAt?: unknown };
|
|
31
|
+
if (typeof record.toolCallId !== "string" || record.toolCallId.length === 0) return undefined;
|
|
32
|
+
if (typeof record.startedAt !== "number" || !Number.isFinite(record.startedAt)) return undefined;
|
|
33
|
+
// Local capture + a plain typeof guard: under non-strict null checks a
|
|
34
|
+
// `x === undefined` condition leaves `unknown` unnarrowed, so the finite-
|
|
35
|
+
// number branch must be decided by typeof before the value is used.
|
|
36
|
+
const endedAt = record.endedAt;
|
|
37
|
+
if (endedAt !== undefined) {
|
|
38
|
+
if (typeof endedAt !== "number" || !Number.isFinite(endedAt)) return undefined;
|
|
39
|
+
return { toolCallId: record.toolCallId, startedAt: record.startedAt, endedAt };
|
|
40
|
+
}
|
|
41
|
+
return { toolCallId: record.toolCallId, startedAt: record.startedAt };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Replay: entries are append-only, so the last timing entry per toolCallId wins. */
|
|
45
|
+
export function readGentleAiTimings(entries: readonly GentleAiTimingEntry[]): Map<string, GentleAiTimingRecord> {
|
|
46
|
+
const timings = new Map<string, GentleAiTimingRecord>();
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.type !== "custom" || entry.customType !== GENTLE_AI_TIMING_ENTRY) continue;
|
|
49
|
+
const record = parseGentleAiTimingData(entry.data);
|
|
50
|
+
if (record) timings.set(record.toolCallId, record);
|
|
51
|
+
}
|
|
52
|
+
return timings;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// No global store: the owning extension binds its own Pi API, and reading all
|
|
56
|
+
// entries also catches a launch that happened while the shell was absent.
|
|
57
|
+
export class GentleAiElapsedTimingLedger implements GentleAiTimingLookup {
|
|
58
|
+
private readonly recorded = new Set<string>();
|
|
59
|
+
private readonly session: GentleAiTimingSessionReader;
|
|
60
|
+
private readonly host?: GentleAiTimingAppendHost;
|
|
61
|
+
|
|
62
|
+
constructor(session: GentleAiTimingSessionReader, host?: GentleAiTimingAppendHost) {
|
|
63
|
+
this.session = session;
|
|
64
|
+
this.host = host;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
lookup(toolCallId: string): GentleAiTimingRecord | undefined {
|
|
68
|
+
return readGentleAiTimings(this.session.getEntries()).get(toolCallId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
recordStart(toolCallId: string, startedAt: number): void {
|
|
72
|
+
this.append(`${toolCallId}:start`, { toolCallId, startedAt });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
recordEnd(toolCallId: string, endedAt: number): void {
|
|
76
|
+
const startedAt = this.lookup(toolCallId)?.startedAt;
|
|
77
|
+
// A terminal without a durable start has no honest duration to freeze.
|
|
78
|
+
if (startedAt === undefined) return;
|
|
79
|
+
this.append(`${toolCallId}:end`, { toolCallId, startedAt, endedAt });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private append(stage: string, data: GentleAiTimingRecord): void {
|
|
83
|
+
if (this.recorded.has(stage)) return;
|
|
84
|
+
this.host?.appendEntry(GENTLE_AI_TIMING_ENTRY, data);
|
|
85
|
+
this.recorded.add(stage);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { keyHint, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { type GentleAiTimingLookup } from "./gentle-ai-elapsed-store.ts";
|
|
4
|
+
import { CARD_TONE, cardBodyRows, cardBottom, cardInnerWidth, cardLine, cardTop, floatRows, type Card, type CardTheme, type CardTone } from "./shell-card.ts";
|
|
5
|
+
import { formatElapsed } from "./agents-widget.ts";
|
|
6
|
+
import { sanitizeTerminalText, stripAnsi } from "./terminal-theme.ts";
|
|
5
7
|
|
|
6
8
|
// Gentle AI tool cards: every call into the gentle-ai binary and every
|
|
7
9
|
// gentle_review tool draws the same card as the other Gentle notices. The
|
|
@@ -18,6 +20,14 @@ export interface GentleAiRenderState {
|
|
|
18
20
|
* call (which pi never marks as started) still shows its outcome. */
|
|
19
21
|
finished?: boolean;
|
|
20
22
|
failed?: boolean;
|
|
23
|
+
/** Wall-clock ms of the first LIVE non-terminal observation — replays never
|
|
24
|
+
* stamp a start — and of the terminal freeze, so a replayed row still shows
|
|
25
|
+
* the true call duration. */
|
|
26
|
+
startedAt?: number;
|
|
27
|
+
endedAt?: number;
|
|
28
|
+
/** The row's single pending live-duration wake-up. Every render used to stack
|
|
29
|
+
* another untracked timer; a terminal render must leave none behind. */
|
|
30
|
+
pendingTimer?: ReturnType<typeof setTimeout>;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
export interface GentleAiRenderContext {
|
|
@@ -29,6 +39,11 @@ export interface GentleAiRenderContext {
|
|
|
29
39
|
lastComponent?: unknown;
|
|
30
40
|
state?: unknown;
|
|
31
41
|
invalidate?: () => void;
|
|
42
|
+
/** pi's stable id for this tool execution; keys the durable timing lookup. */
|
|
43
|
+
toolCallId?: string;
|
|
44
|
+
/** Durable timing source (session entries). pi's row `state` is render-local,
|
|
45
|
+
* so only this survives the fresh state a historical replay constructs. */
|
|
46
|
+
elapsedTiming?: GentleAiTimingLookup;
|
|
32
47
|
}
|
|
33
48
|
|
|
34
49
|
const LIFECYCLE_STATUS = {
|
|
@@ -47,13 +62,28 @@ const STATUS_TONE: Record<LifecycleStatus, CardTone> = {
|
|
|
47
62
|
[LIFECYCLE_STATUS.FAILED]: CARD_TONE.ERROR,
|
|
48
63
|
};
|
|
49
64
|
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
65
|
+
// The title reads like the quiet read card: a short name, then the operation
|
|
66
|
+
// as its argument. Review operations are `rdd <op>`; any other binary call
|
|
67
|
+
// is `gentle-ai <op>`.
|
|
68
|
+
const REVIEW_PREFIX = "review";
|
|
69
|
+
const REVIEW_TITLE = "rdd";
|
|
70
|
+
const BINARY_TITLE = "gentle-ai";
|
|
71
|
+
const SEPARATOR = " · ";
|
|
72
|
+
// The binary keeps its rose as a colored emoji (2 cells, no text-presentation
|
|
73
|
+
// selector); Gentle Shell notices keep the flower.
|
|
74
|
+
const CARD_GLYPH = "\u{1F339}";
|
|
53
75
|
const DETAIL_ROLE = "dim";
|
|
54
|
-
const HIDDEN_ROLE = "dim";
|
|
55
76
|
const passthroughTheme: CardTheme = { fg: (_color, text) => text };
|
|
56
77
|
|
|
78
|
+
// The running/failed state stays visible until the call completes.
|
|
79
|
+
function callTitle(status: LifecycleStatus, operationPath: string): string {
|
|
80
|
+
const review = operationPath === REVIEW_PREFIX || operationPath.startsWith(`${REVIEW_PREFIX} `);
|
|
81
|
+
const name = review ? REVIEW_TITLE : BINARY_TITLE;
|
|
82
|
+
const operation = review ? operationPath.slice(REVIEW_PREFIX.length).trim() : operationPath;
|
|
83
|
+
const argument = [status === LIFECYCLE_STATUS.COMPLETED ? "" : status, operation].filter((part) => part.length > 0).join(SEPARATOR);
|
|
84
|
+
return argument.length > 0 ? `${name} ${argument}` : name;
|
|
85
|
+
}
|
|
86
|
+
|
|
57
87
|
export function getGentleAiRenderState(state: unknown): GentleAiRenderState | undefined {
|
|
58
88
|
if (!state || typeof state !== "object" || Array.isArray(state)) return undefined;
|
|
59
89
|
const rowState = state as Record<string, unknown>, existing = rowState.gentleAiRender;
|
|
@@ -68,65 +98,140 @@ export function getGentleAiRenderState(state: unknown): GentleAiRenderState | un
|
|
|
68
98
|
// also closes the frame, because no result row exists yet; once a final
|
|
69
99
|
// result is in, the result card closes it instead.
|
|
70
100
|
export class GentleAiCallCard {
|
|
71
|
-
private card: Card = { title:
|
|
101
|
+
private card: Card = { title: REVIEW_TITLE, body: [], tone: CARD_TONE.WARNING, glyph: CARD_GLYPH };
|
|
72
102
|
private theme: GentleAiRenderTheme = passthroughTheme;
|
|
73
103
|
private detail: string | undefined;
|
|
74
104
|
private hint: string | undefined;
|
|
105
|
+
private elapsed = "";
|
|
75
106
|
private open = true;
|
|
76
107
|
|
|
77
|
-
update(status: LifecycleStatus, operationPath: string, theme: GentleAiRenderTheme, detail?: string, hint?: string): void {
|
|
78
|
-
this.card = { title:
|
|
108
|
+
update(status: LifecycleStatus, operationPath: string, theme: GentleAiRenderTheme, detail?: string, hint?: string, elapsed?: string): void {
|
|
109
|
+
this.card = { title: callTitle(status, operationPath), body: [], tone: STATUS_TONE[status], glyph: CARD_GLYPH };
|
|
79
110
|
this.theme = theme;
|
|
80
111
|
this.detail = detail;
|
|
81
112
|
this.hint = hint;
|
|
113
|
+
this.elapsed = elapsed ?? "";
|
|
82
114
|
this.open = status === LIFECYCLE_STATUS.RUNNING || status === LIFECYCLE_STATUS.PREPARING;
|
|
83
115
|
}
|
|
84
116
|
|
|
85
117
|
render(width: number): string[] {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
118
|
+
if (width <= 0) return [];
|
|
119
|
+
// The command detail belongs to the heading; the result below owns the body.
|
|
120
|
+
return floatRows(this.card.tone, this.theme, width, (inner) => ({
|
|
121
|
+
head: [cardTop(this.card, this.theme, inner, this.hint), ...(this.detail ? [cardLine(this.theme.fg(DETAIL_ROLE, this.detail), this.card.tone, this.theme, inner)] : [])],
|
|
122
|
+
bottom: this.open ? cardBottom(this.card.tone, this.theme, inner, this.elapsed || undefined) : undefined,
|
|
123
|
+
}));
|
|
90
124
|
}
|
|
91
125
|
|
|
92
126
|
invalidate(): void {}
|
|
93
127
|
}
|
|
94
128
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
129
|
+
// JSON envelopes collapse to one human line built from these fields, in this
|
|
130
|
+
// order, looked up at the top level and then inside `result`. Each group
|
|
131
|
+
// contributes its first present value. Schemas, hashes, bindings and command
|
|
132
|
+
// strings are never listed, so they never reach the summary.
|
|
133
|
+
const SUMMARY_FIELD_GROUPS: readonly (readonly string[])[] = [
|
|
134
|
+
["status", "state"],
|
|
135
|
+
["outcome"],
|
|
136
|
+
["risk", "risk_tier", "riskLevel"],
|
|
137
|
+
["action", "provider_action"],
|
|
138
|
+
["next_transition.reason_code", "nextTransition.reasonCode", "next_transition", "nextTransition", "reason_code", "reasonCode", "diagnostics.error_code"],
|
|
139
|
+
["diagnostics.message", "diagnostics.stderr", "error.message", "error", "reasons.0.detail", "reason", "message"],
|
|
140
|
+
];
|
|
141
|
+
const HASH_PATTERN = /^(?:sha256:)?[0-9a-f]{32,}$/i;
|
|
142
|
+
|
|
143
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
144
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseJsonObject(text: string): Record<string, unknown> | undefined {
|
|
148
|
+
const trimmed = text.trim();
|
|
149
|
+
if (!trimmed.startsWith("{")) return undefined;
|
|
150
|
+
try {
|
|
151
|
+
const value: unknown = JSON.parse(trimmed);
|
|
152
|
+
return isJsonObject(value) ? value : undefined;
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function fieldAt(source: Record<string, unknown>, path: string): unknown {
|
|
159
|
+
let value: unknown = source;
|
|
160
|
+
for (const key of path.split(".")) {
|
|
161
|
+
if (Array.isArray(value)) value = value[Number(key)];
|
|
162
|
+
else if (isJsonObject(value)) value = value[key];
|
|
163
|
+
else return undefined;
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// One readable clause: first line, first sentence part, no nested "Error: "
|
|
169
|
+
// prefixes, and no control characters decoded from JSON escapes.
|
|
170
|
+
function summaryValue(value: unknown): string | undefined {
|
|
171
|
+
if (typeof value !== "string") return undefined;
|
|
172
|
+
const clause = sanitizeTerminalText(value).split("\n")[0]!.split("; ")[0]!
|
|
173
|
+
.replace(/(^|: )\w*Error: /g, "$1").replace(/\s+/g, " ").trim();
|
|
174
|
+
return clause.length > 0 && !HASH_PATTERN.test(clause) ? clause : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function summarizeJsonEnvelope(envelope: Record<string, unknown>): string {
|
|
178
|
+
const scopes = isJsonObject(envelope.result) ? [envelope, envelope.result] : [envelope];
|
|
179
|
+
const parts: string[] = [];
|
|
180
|
+
for (const group of SUMMARY_FIELD_GROUPS) {
|
|
181
|
+
const found = scopes.flatMap((scope) => group.map((path) => summaryValue(fieldAt(scope, path)))).find((part) => part !== undefined);
|
|
182
|
+
if (found !== undefined && !parts.includes(found)) parts.push(found);
|
|
183
|
+
}
|
|
184
|
+
if (parts.length > 0) return parts.join(SEPARATOR);
|
|
185
|
+
const count = Object.keys(envelope).length;
|
|
186
|
+
return `${count} ${count === 1 ? "field" : "fields"}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The result rows: complete output when expanded, a useful bounded preview
|
|
190
|
+
// when collapsed (the call card carries the expand key), and
|
|
97
191
|
// always the bottom rule that closes the frame. The rail follows the outcome:
|
|
98
|
-
// amber while partial, green when done, red on error.
|
|
192
|
+
// amber while partial, green when done, red on error. A JSON envelope
|
|
193
|
+
// collapses to one summary row and expands pretty-printed.
|
|
99
194
|
export class GentleAiResultCard {
|
|
100
195
|
private readonly text: string;
|
|
196
|
+
private readonly envelope: Record<string, unknown> | undefined;
|
|
101
197
|
private readonly expanded: boolean;
|
|
102
198
|
private readonly tone: CardTone;
|
|
103
199
|
private readonly theme: GentleAiRenderTheme;
|
|
104
200
|
private readonly partial: boolean;
|
|
201
|
+
private readonly elapsed: string;
|
|
105
202
|
|
|
106
|
-
constructor(text: string, expanded: boolean, tone: CardTone, theme: GentleAiRenderTheme, partial = false) {
|
|
107
|
-
this.
|
|
203
|
+
constructor(text: string, expanded: boolean, tone: CardTone, theme: GentleAiRenderTheme, partial = false, elapsed = "") {
|
|
204
|
+
this.envelope = parseJsonObject(text);
|
|
205
|
+
this.text = this.envelope !== undefined && expanded ? JSON.stringify(this.envelope, null, 2) : text;
|
|
108
206
|
this.expanded = expanded;
|
|
109
207
|
this.tone = tone;
|
|
110
208
|
this.theme = theme;
|
|
111
209
|
this.partial = partial;
|
|
210
|
+
this.elapsed = elapsed;
|
|
112
211
|
}
|
|
113
212
|
|
|
114
213
|
render(width: number): string[] {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
214
|
+
if (width <= 0) return [];
|
|
215
|
+
const role = this.tone === CARD_TONE.ERROR ? "error" : "toolOutput";
|
|
216
|
+
// A partial result sits under a running call card, which still closes the
|
|
217
|
+
// frame; there, the call's closing row already separates the body.
|
|
218
|
+
return floatRows(this.tone, this.theme, width, (inner) => ({
|
|
219
|
+
body: this.body(inner, role),
|
|
220
|
+
bottom: this.partial ? undefined : cardBottom(this.tone, this.theme, inner, this.elapsed || undefined),
|
|
221
|
+
afterHeading: !this.partial,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private body(width: number, role: string): string[] {
|
|
226
|
+
if (this.envelope !== undefined && !this.expanded) {
|
|
227
|
+
const summary = truncateToWidth(summarizeJsonEnvelope(this.envelope), cardInnerWidth(width), "…");
|
|
228
|
+
return [cardLine(this.theme.fg(role, summary), this.tone, this.theme, width)];
|
|
126
229
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return
|
|
230
|
+
const rows = this.text.length > 0 ? this.text.split("\n") : [];
|
|
231
|
+
const useful = this.expanded ? rows : rows.filter((row) => stripAnsi(row).trim().length > 0);
|
|
232
|
+
return cardBodyRows(useful.map((row) => this.theme.fg(role, row)), this.tone, this.theme, width, {
|
|
233
|
+
expanded: this.expanded, previewRows: 3,
|
|
234
|
+
});
|
|
130
235
|
}
|
|
131
236
|
|
|
132
237
|
invalidate(): void {}
|
|
@@ -146,18 +251,22 @@ export function renderGentleAiResult(
|
|
|
146
251
|
): GentleAiResultCard {
|
|
147
252
|
const textItems = result.content.flatMap((content) => (content.type === "text" ? [sanitizeTerminalText(content.text)] : []));
|
|
148
253
|
const text = textItems.some((item) => item.length > 0) ? textItems.join("\n") : "";
|
|
149
|
-
const
|
|
254
|
+
const isError = context?.isError ?? options.isError ?? false;
|
|
255
|
+
const tone = isError ? CARD_TONE.ERROR : options.isPartial ? CARD_TONE.WARNING : CARD_TONE.SUCCESS;
|
|
150
256
|
const state = getGentleAiRenderState(context?.state);
|
|
257
|
+
// The frozen duration rides the closing rule, right-aligned.
|
|
258
|
+
let elapsed: string | undefined;
|
|
259
|
+
if (state?.startedAt !== undefined && state.endedAt !== undefined) elapsed = formatElapsed(state.endedAt - state.startedAt);
|
|
151
260
|
if (state && options.isPartial !== true) {
|
|
152
|
-
const changed = state.finished !== true || state.failed !==
|
|
261
|
+
const changed = state.finished !== true || state.failed !== isError;
|
|
153
262
|
state.finished = true;
|
|
154
|
-
state.failed =
|
|
263
|
+
state.failed = isError;
|
|
155
264
|
// pi's invalidate re-runs the tool display synchronously; called from
|
|
156
265
|
// inside this render it would nest a second call+result pair into the
|
|
157
266
|
// same container. Deferring it keeps one frame per execution.
|
|
158
267
|
if (changed) queueMicrotask(() => context?.invalidate?.());
|
|
159
268
|
}
|
|
160
|
-
return new GentleAiResultCard(text, options.expanded === true, tone, theme, options.isPartial === true);
|
|
269
|
+
return new GentleAiResultCard(text, options.expanded === true, tone, theme, options.isPartial === true, elapsed ?? "");
|
|
161
270
|
}
|
|
162
271
|
|
|
163
272
|
export function renderGentleAiLifecycleCall(
|
|
@@ -165,10 +274,24 @@ export function renderGentleAiLifecycleCall(
|
|
|
165
274
|
theme: GentleAiRenderTheme,
|
|
166
275
|
context?: GentleAiRenderContext,
|
|
167
276
|
detail?: string,
|
|
277
|
+
now: number = Date.now(),
|
|
168
278
|
): GentleAiCallCard {
|
|
169
279
|
// A finished execution is completed even when pi replays it without
|
|
170
280
|
// argsComplete (session reload); preparing only applies before it starts.
|
|
171
281
|
const state = getGentleAiRenderState(context?.state);
|
|
282
|
+
// Durable timestamps live in session entries; the row state is render-local
|
|
283
|
+
// and a replay constructs a fresh one. Seed from the durable record for this
|
|
284
|
+
// tool call; without one the stamps stay unset and the card stays honest.
|
|
285
|
+
if (state && state.startedAt === undefined && state.endedAt === undefined && typeof context?.toolCallId === "string") {
|
|
286
|
+
const durable = context.elapsedTiming?.lookup(context.toolCallId);
|
|
287
|
+
// Only a complete start+end record restores: a start-only record has no
|
|
288
|
+
// honest duration, and seeding its start would make a replayed card tick
|
|
289
|
+
// against the replay clock instead of the execution that ended long ago.
|
|
290
|
+
if (durable?.endedAt !== undefined) {
|
|
291
|
+
state.startedAt = durable.startedAt;
|
|
292
|
+
state.endedAt = durable.endedAt;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
172
295
|
const finished = (context?.executionStarted === true && context.isPartial !== true) || state?.finished === true;
|
|
173
296
|
const failed = context?.isError === true || state?.failed === true;
|
|
174
297
|
const status: LifecycleStatus = failed
|
|
@@ -178,11 +301,45 @@ export function renderGentleAiLifecycleCall(
|
|
|
178
301
|
: context?.argsComplete === false
|
|
179
302
|
? LIFECYCLE_STATUS.PREPARING
|
|
180
303
|
: LIFECYCLE_STATUS.RUNNING;
|
|
304
|
+
if (state) {
|
|
305
|
+
if (status === LIFECYCLE_STATUS.COMPLETED || status === LIFECYCLE_STATUS.FAILED) {
|
|
306
|
+
// Only a live terminal observation may freeze the end (pi never raises
|
|
307
|
+
// executionStarted on replayed rows): a replayed start-only record would
|
|
308
|
+
// otherwise grow an invented end at replay time.
|
|
309
|
+
if (state.startedAt !== undefined && context?.executionStarted === true) state.endedAt ??= now;
|
|
310
|
+
} else if ((status === LIFECYCLE_STATUS.RUNNING && context?.argsComplete === true) || context?.executionStarted === true) {
|
|
311
|
+
// Stamp only on live evidence: a live running row carries argsComplete
|
|
312
|
+
// (true), while a replayed row omits it entirely — an unexplained RUNNING
|
|
313
|
+
// on a historical row must not fabricate a start. executionStarted never
|
|
314
|
+
// fires on replays.
|
|
315
|
+
state.startedAt ??= now;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Elapsed is live from the first observation: every re-render recomputes it
|
|
319
|
+
// from now, and the terminal freeze keeps the final value stable.
|
|
320
|
+
const elapsed = state?.startedAt === undefined ? "" : formatElapsed((state.endedAt ?? now) - state.startedAt);
|
|
321
|
+
// Hint: the expand key only — the elapsed lives on the bottom rule.
|
|
322
|
+
const expandHint = finished ? stripAnsi(keyHint("app.tools.expand", context?.expanded ? "to collapse" : "to expand")) : undefined;
|
|
323
|
+
const hint = [expandHint].filter((part): part is string => part !== undefined && part.length > 0).join(" · ");
|
|
181
324
|
const component = context?.lastComponent instanceof GentleAiCallCard && (!state || state.lifecycleComponent === true)
|
|
182
325
|
? context.lastComponent
|
|
183
326
|
: new GentleAiCallCard();
|
|
184
327
|
if (state) state.lifecycleComponent = true;
|
|
185
|
-
|
|
186
|
-
|
|
328
|
+
component.update(status, operationPath, theme, detail ? sanitizeTerminalText(detail) : undefined, hint, elapsed);
|
|
329
|
+
// While the call runs, wake the row once a second so the live duration ticks.
|
|
330
|
+
// At most one pending timer per row: frequent renders must not stack
|
|
331
|
+
// independent invalidation chains, and none may outlive the terminal render.
|
|
332
|
+
if (state) {
|
|
333
|
+
if (state.pendingTimer !== undefined) clearTimeout(state.pendingTimer);
|
|
334
|
+
if ((status === LIFECYCLE_STATUS.RUNNING || status === LIFECYCLE_STATUS.PREPARING) && state.startedAt !== undefined && state.endedAt === undefined) {
|
|
335
|
+
state.pendingTimer = setTimeout(() => {
|
|
336
|
+
state.pendingTimer = undefined;
|
|
337
|
+
context?.invalidate?.();
|
|
338
|
+
}, 1000);
|
|
339
|
+
state.pendingTimer.unref?.();
|
|
340
|
+
} else {
|
|
341
|
+
state.pendingTimer = undefined;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
187
344
|
return component;
|
|
188
345
|
}
|