@cspeach/cli 1.0.0 → 1.1.1
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/dist/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +71 -78
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- package/package.json +4 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/run-lease.ts
|
|
2
|
+
//
|
|
3
|
+
// Task 9 (agentic-flow, 2026-07-03) — run lease: concurrent-resume guard for
|
|
4
|
+
// plan envelopes.
|
|
5
|
+
//
|
|
6
|
+
// Problem: two sessions resuming the same plan fork sibling envelope
|
|
7
|
+
// versions silently — saveProject appends collision suffixes and the
|
|
8
|
+
// newest-version redirect tie-breaks on mtime, so neither session notices
|
|
9
|
+
// the other. Guarded mode's long unattended chains make a double-resume
|
|
10
|
+
// likelier. A lightweight lease file makes the second session STOP and say
|
|
11
|
+
// who is already running.
|
|
12
|
+
//
|
|
13
|
+
// Design choices (pinned by __tests__/run-lease.test.ts):
|
|
14
|
+
//
|
|
15
|
+
// - FAMILY-KEYED: the lock path strips the `-vN[-M]` version suffix (same
|
|
16
|
+
// convention as handoverPathFor in handover-md.ts), so the v3→v4 rotation
|
|
17
|
+
// every phase save performs does NOT drop the lease —
|
|
18
|
+
// `hr-plan-ab12-v3.cspeach.json` and `...-v4.cspeach.json` share
|
|
19
|
+
// `hr-plan-ab12.cspeach.lock`. The FAMILY is the thing being resumed;
|
|
20
|
+
// individual version files are just its snapshots.
|
|
21
|
+
//
|
|
22
|
+
// - SAME-SESSION IDEMPOTENT: re-acquiring with the sessionId already on the
|
|
23
|
+
// lease succeeds (and refreshes it). The plan chain re-enters
|
|
24
|
+
// preparePlanResume on EVERY phase via queueDispatch — without this the
|
|
25
|
+
// guarded chain would deadlock on its own lease.
|
|
26
|
+
//
|
|
27
|
+
// - STALENESS BY PID LIVENESS: a lease whose holder pid is not alive
|
|
28
|
+
// (process.kill(pid, 0) throws — works for liveness probing on Windows
|
|
29
|
+
// Node too) is silently replaced. A crashed or killed session therefore
|
|
30
|
+
// never needs manual cleanup.
|
|
31
|
+
//
|
|
32
|
+
// - BEST-EFFORT, NOT A SECURITY BOUNDARY: any fs failure while checking or
|
|
33
|
+
// writing the lease logs one line and reports `acquired: true` —
|
|
34
|
+
// availability over strictness; a resume must never be blocked by a
|
|
35
|
+
// broken lock file or a read-only workspace. This is a courtesy guard
|
|
36
|
+
// against accidental double-resume, nothing more.
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import { basename, dirname, join } from 'node:path';
|
|
39
|
+
/**
|
|
40
|
+
* The lease file for an envelope path — keyed on the plan FAMILY base
|
|
41
|
+
* (version suffix `-vN` and any `-vN-M` collision sub-suffix stripped, same
|
|
42
|
+
* convention as handoverPathFor), so every version of one plan family maps
|
|
43
|
+
* to ONE lock file. Non-envelope paths fall back to `<path>.lock`.
|
|
44
|
+
*/
|
|
45
|
+
export function leasePathFor(envelopePath) {
|
|
46
|
+
const name = basename(envelopePath);
|
|
47
|
+
if (!name.endsWith('.cspeach.json'))
|
|
48
|
+
return `${envelopePath}.lock`;
|
|
49
|
+
const base = name.replace(/(?:-v\d+(?:-\d+)?)?\.cspeach\.json$/, '');
|
|
50
|
+
return join(dirname(envelopePath), `${base}.cspeach.lock`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Liveness probe: signal 0 never delivers, only checks existence. On
|
|
54
|
+
* Windows Node this maps to an OpenProcess check — valid for liveness.
|
|
55
|
+
* EPERM means "alive but not ours to signal" — that IS alive.
|
|
56
|
+
*/
|
|
57
|
+
function isPidAlive(pid) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return e.code === 'EPERM';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Try to take the run lease for a plan envelope's family.
|
|
68
|
+
*
|
|
69
|
+
* - no lease / stale lease (dead pid) / corrupt lock → acquired, silently
|
|
70
|
+
* - lease held by the SAME sessionId → acquired (idempotent re-entry;
|
|
71
|
+
* the file is refreshed with the current pid + timestamp)
|
|
72
|
+
* - lease held by a LIVE other session → `{ acquired: false, holder }`
|
|
73
|
+
* - `force: true` → acquired unconditionally (caller obtained user consent)
|
|
74
|
+
* - ANY fs error → `{ acquired: true }` after one log line (best-effort)
|
|
75
|
+
*
|
|
76
|
+
* The write is atomic (tmp + rename, same style as session/store.ts) so a
|
|
77
|
+
* concurrent reader never sees a half-written lock.
|
|
78
|
+
*/
|
|
79
|
+
export function acquireRunLease(envelopePath, sessionId, opts) {
|
|
80
|
+
const lockPath = leasePathFor(envelopePath);
|
|
81
|
+
try {
|
|
82
|
+
if (!opts?.force) {
|
|
83
|
+
let existing = null;
|
|
84
|
+
try {
|
|
85
|
+
const raw = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
86
|
+
if (typeof raw.pid === 'number' && typeof raw.sessionId === 'string') {
|
|
87
|
+
existing = { pid: raw.pid, sessionId: raw.sessionId, at: typeof raw.at === 'string' ? raw.at : '' };
|
|
88
|
+
}
|
|
89
|
+
// Malformed shape → existing stays null: garbage never holds a lease.
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
existing = null; // missing or unparseable — treat as absent
|
|
93
|
+
}
|
|
94
|
+
if (existing && existing.sessionId !== sessionId && isPidAlive(existing.pid)) {
|
|
95
|
+
return { acquired: false, holder: existing };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const holder = { pid: process.pid, sessionId, at: new Date().toISOString() };
|
|
99
|
+
const tmp = `${lockPath}.tmp`;
|
|
100
|
+
fs.writeFileSync(tmp, JSON.stringify(holder, null, 2), 'utf8');
|
|
101
|
+
try {
|
|
102
|
+
fs.renameSync(tmp, lockPath);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
// Don't orphan the tmp file — best-effort cleanup, then let the outer
|
|
106
|
+
// catch handle the failure as usual (proceed-as-acquired).
|
|
107
|
+
try {
|
|
108
|
+
fs.unlinkSync(tmp);
|
|
109
|
+
}
|
|
110
|
+
catch { /* best-effort */ }
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
return { acquired: true, holder };
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
// Best-effort by design: a broken lock dir must never block a resume.
|
|
117
|
+
opts?.log?.(`[plan] run-lease check skipped (${e instanceof Error ? e.message : String(e)}) — proceeding without the concurrency guard.`);
|
|
118
|
+
return { acquired: true };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Release the run lease for a plan envelope's family. Ownership-checked:
|
|
123
|
+
* a lock held by a DIFFERENT live pid (e.g. another session overrode us
|
|
124
|
+
* mid-run with user consent) is left untouched — we only ever delete our
|
|
125
|
+
* own, or a dead holder's, lock. Best-effort: all failures are swallowed
|
|
126
|
+
* (a leaked lease goes stale with the process anyway).
|
|
127
|
+
*/
|
|
128
|
+
export function releaseRunLease(envelopePath) {
|
|
129
|
+
const lockPath = leasePathFor(envelopePath);
|
|
130
|
+
try {
|
|
131
|
+
const raw = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
132
|
+
if (typeof raw.pid === 'number' && raw.pid !== process.pid && isPidAlive(raw.pid)) {
|
|
133
|
+
return; // live foreign holder — never delete someone else's lease
|
|
134
|
+
}
|
|
135
|
+
fs.unlinkSync(lockPath);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// missing / unreadable / undeletable — nothing useful to do
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Chain-aware release for the REPL's plan-resume legs: when a follow-up
|
|
143
|
+
* dispatch is QUEUED (`queuedDispatch` non-empty — the pendingDispatch slot
|
|
144
|
+
* still holds the harness-built next `--resume` command), the lease is HELD
|
|
145
|
+
* across the gap. Releasing between auto-chained phases would let a second
|
|
146
|
+
* session grab the family lock mid-chain (headless: the chain silently
|
|
147
|
+
* stops on re-entry; interactive: the user is prompted mid-auto-chain, and
|
|
148
|
+
* a takeover forks the plan — the exact failure the lease exists to
|
|
149
|
+
* prevent). The re-entry's same-session re-acquire is idempotent, so
|
|
150
|
+
* holding through the gap is free. Only when the chain truly ends (nothing
|
|
151
|
+
* queued) is the lease released.
|
|
152
|
+
*/
|
|
153
|
+
export function releaseRunLeaseUnlessChained(envelopePath, queuedDispatch) {
|
|
154
|
+
if (queuedDispatch)
|
|
155
|
+
return; // chain continues — hold the lease across the dispatch
|
|
156
|
+
releaseRunLease(envelopePath);
|
|
157
|
+
}
|
|
@@ -15,6 +15,7 @@ import { validateEnvelope } from './validate.js';
|
|
|
15
15
|
import { readProjectFile } from './status.js';
|
|
16
16
|
import { saveProject } from './save.js';
|
|
17
17
|
import { renderEmailTemplate } from './email-template.js';
|
|
18
|
+
import { writePlanHandover } from './handover-md.js';
|
|
18
19
|
import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
|
|
19
20
|
const SKILL_REGISTRY = {
|
|
20
21
|
'abap-spec-gap': {
|
|
@@ -36,19 +37,19 @@ const SKILL_REGISTRY = {
|
|
|
36
37
|
artefactType: 'upgrade-scan',
|
|
37
38
|
extract: (md) => extractUpgradeScan(md),
|
|
38
39
|
itemNoun: 'findings',
|
|
39
|
-
manifestMarker: '
|
|
40
|
+
manifestMarker: 'upgrade-manifest',
|
|
40
41
|
},
|
|
41
42
|
'abap-upgrade-fix': {
|
|
42
43
|
artefactType: 'upgrade-progress',
|
|
43
44
|
extract: (md) => extractUpgradeProgress(md),
|
|
44
45
|
itemNoun: 'fixes',
|
|
45
|
-
manifestMarker: '
|
|
46
|
+
manifestMarker: 'upgrade-manifest',
|
|
46
47
|
},
|
|
47
48
|
'abap-upgrade-verify': {
|
|
48
49
|
artefactType: 'upgrade-report',
|
|
49
50
|
extract: (md) => extractUpgradeReport(md),
|
|
50
51
|
itemNoun: 'regressions',
|
|
51
|
-
manifestMarker: '
|
|
52
|
+
manifestMarker: 'upgrade-manifest',
|
|
52
53
|
},
|
|
53
54
|
'abap-upgrade-merge': {
|
|
54
55
|
// Merge produces a NEW upgrade-progress (consolidated) — same shape
|
|
@@ -56,13 +57,13 @@ const SKILL_REGISTRY = {
|
|
|
56
57
|
artefactType: 'upgrade-progress',
|
|
57
58
|
extract: (md) => extractUpgradeProgress(md),
|
|
58
59
|
itemNoun: 'fixes',
|
|
59
|
-
manifestMarker: '
|
|
60
|
+
manifestMarker: 'upgrade-manifest',
|
|
60
61
|
},
|
|
61
62
|
'abap-cca': {
|
|
62
63
|
artefactType: 'cca-assessment',
|
|
63
64
|
extract: (md) => extractCcaAssessment(md),
|
|
64
65
|
itemNoun: 'classifications',
|
|
65
|
-
manifestMarker: '
|
|
66
|
+
manifestMarker: 'cca-manifest',
|
|
66
67
|
},
|
|
67
68
|
'abap-cca-merge': {
|
|
68
69
|
// Merge produces a NEW cca-assessment (consolidated) — same shape as
|
|
@@ -70,19 +71,19 @@ const SKILL_REGISTRY = {
|
|
|
70
71
|
artefactType: 'cca-assessment',
|
|
71
72
|
extract: (md) => extractCcaAssessment(md),
|
|
72
73
|
itemNoun: 'classifications',
|
|
73
|
-
manifestMarker: '
|
|
74
|
+
manifestMarker: 'cca-manifest',
|
|
74
75
|
},
|
|
75
76
|
'abap-modernize': {
|
|
76
77
|
artefactType: 'modernize-result',
|
|
77
78
|
extract: (md) => extractModernizeResult(md),
|
|
78
79
|
itemNoun: 'modernizations',
|
|
79
|
-
manifestMarker: '
|
|
80
|
+
manifestMarker: 'modernize-manifest',
|
|
80
81
|
},
|
|
81
82
|
'abap-test': {
|
|
82
83
|
artefactType: 'test-coverage',
|
|
83
84
|
extract: (md) => extractTestCoverage(md),
|
|
84
85
|
itemNoun: 'tests',
|
|
85
|
-
manifestMarker: '
|
|
86
|
+
manifestMarker: 'test-coverage-manifest',
|
|
86
87
|
},
|
|
87
88
|
'abap-plan': {
|
|
88
89
|
// 2026-06-06 B3: Mode 1 (create) save. Mode 2 (--resume) turns
|
|
@@ -97,7 +98,7 @@ const SKILL_REGISTRY = {
|
|
|
97
98
|
artefactType: 'plan',
|
|
98
99
|
extract: (md) => extractPlan(md),
|
|
99
100
|
itemNoun: 'phases',
|
|
100
|
-
manifestMarker: '
|
|
101
|
+
manifestMarker: 'plan-manifest',
|
|
101
102
|
},
|
|
102
103
|
};
|
|
103
104
|
/**
|
|
@@ -114,15 +115,17 @@ const SKILL_REGISTRY = {
|
|
|
114
115
|
* inside the block disambiguates which extractor (registry skill) applies.
|
|
115
116
|
*/
|
|
116
117
|
export function detectArtifactSkill(markdown) {
|
|
117
|
-
|
|
118
|
+
// E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
|
|
119
|
+
// marker prefixes (legacy acceptance is permanent).
|
|
120
|
+
if (/<!--\s*(?:csforge|cspeach):plan-manifest\b/.test(markdown))
|
|
118
121
|
return 'abap-plan';
|
|
119
|
-
if (/<!--\s*csforge:cca-manifest\b/.test(markdown))
|
|
122
|
+
if (/<!--\s*(?:csforge|cspeach):cca-manifest\b/.test(markdown))
|
|
120
123
|
return 'abap-cca';
|
|
121
|
-
if (/<!--\s*csforge:modernize-manifest\b/.test(markdown))
|
|
124
|
+
if (/<!--\s*(?:csforge|cspeach):modernize-manifest\b/.test(markdown))
|
|
122
125
|
return 'abap-modernize';
|
|
123
|
-
if (/<!--\s*csforge:test-coverage-manifest\b/.test(markdown))
|
|
126
|
+
if (/<!--\s*(?:csforge|cspeach):test-coverage-manifest\b/.test(markdown))
|
|
124
127
|
return 'abap-test';
|
|
125
|
-
const up = /<!--\s*csforge:upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/.exec(markdown);
|
|
128
|
+
const up = /<!--\s*(?:csforge|cspeach):upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/.exec(markdown);
|
|
126
129
|
if (up) {
|
|
127
130
|
const art = /(?:^|\n)\s*artefact:\s*(\S+)/.exec(up[1] ?? '');
|
|
128
131
|
switch (art?.[1]) {
|
|
@@ -135,7 +138,8 @@ export function detectArtifactSkill(markdown) {
|
|
|
135
138
|
return null;
|
|
136
139
|
}
|
|
137
140
|
// Same block grammar as extract-plan.ts:MANIFEST_RE (last block wins).
|
|
138
|
-
|
|
141
|
+
// E2 dual-read: accept both the legacy `csforge:` and current `cspeach:` prefixes.
|
|
142
|
+
const PLAN_MANIFEST_RE = /<!--\s*(?:csforge|cspeach):plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
|
|
139
143
|
/**
|
|
140
144
|
* Cheap title peek into the LAST plan-manifest block — needed BEFORE the full
|
|
141
145
|
* extract because compact manifests can only be expanded against the prior
|
|
@@ -349,6 +353,13 @@ export async function runSaveCommand(args) {
|
|
|
349
353
|
outDir = args.cwd;
|
|
350
354
|
}
|
|
351
355
|
const path = await saveProject(env, { cwd: outDir });
|
|
356
|
+
// Task 8 follow-up (fix-wave): a plan envelope saved through the GENERIC
|
|
357
|
+
// save path (Mode 1 create AND a steered revision, C1/D30) must regenerate
|
|
358
|
+
// the markdown handover projection too — otherwise an authoring-session
|
|
359
|
+
// save leaves a stale or missing projection until the first --resume write.
|
|
360
|
+
// Best-effort by design (writePlanHandover logs one dim line on failure).
|
|
361
|
+
if (spec.artefactType === 'plan')
|
|
362
|
+
writePlanHandover(path, args.log);
|
|
352
363
|
args.log(`Saved: ${path}`);
|
|
353
364
|
if (planExtract && prior) {
|
|
354
365
|
args.log(`(revision v${env.version} of the existing plan — version chain preserved, no new id)`);
|
|
@@ -40,7 +40,7 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
|
|
|
40
40
|
* direct stdout (which would corrupt the live Ink frame).
|
|
41
41
|
*/
|
|
42
42
|
export function formatStatusFooter(params) {
|
|
43
|
-
const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens } = params;
|
|
43
|
+
const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant } = params;
|
|
44
44
|
const shortSession = sessionId.slice(0, 8);
|
|
45
45
|
const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
|
|
46
46
|
const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
|
|
@@ -49,17 +49,27 @@ export function formatStatusFooter(params) {
|
|
|
49
49
|
const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
|
|
50
50
|
const costTurnStr = costThisTurn != null ? formatCost(costThisTurn) : '';
|
|
51
51
|
const costSessStr = costSession != null ? `session: ${formatCost(costSession)}` : '';
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
52
|
+
// Ink turn-receipt (see the variant doc on StatusFooterParams):
|
|
53
|
+
// turn-only facts — everything session-scoped lives in <StatusLine />.
|
|
54
|
+
const parts = variant === 'turn'
|
|
55
|
+
? [
|
|
56
|
+
chalk.dim(skillDisplay),
|
|
57
|
+
chalk.dim(turns),
|
|
58
|
+
chalk.dim(tokStr),
|
|
59
|
+
...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
|
|
60
|
+
...(elapsed ? [chalk.dim(elapsed)] : []),
|
|
61
|
+
]
|
|
62
|
+
: [
|
|
63
|
+
chalk.dim(`session ${shortSession}`),
|
|
64
|
+
chalk.dim(sapAlias),
|
|
65
|
+
chalk.dim(skillDisplay),
|
|
66
|
+
chalk.dim(turns),
|
|
67
|
+
chalk.dim(tokStr),
|
|
68
|
+
...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
|
|
69
|
+
...(cachedStr ? [chalk.dim(cachedStr)] : []),
|
|
70
|
+
...(costSessStr ? [chalk.dim(costSessStr)] : []),
|
|
71
|
+
...(elapsed ? [chalk.dim(elapsed)] : []),
|
|
72
|
+
];
|
|
63
73
|
const lines = ['', RULE, ' ' + parts.join(chalk.dim(' │ '))];
|
|
64
74
|
if (sessionInputTokens != null && sessionInputTokens >= LARGE_SESSION_THRESHOLD) {
|
|
65
75
|
lines.push(' ' + chalk.yellow(`⚠ session getting large (${sessionInputTokens.toLocaleString()} input tokens)`
|
|
@@ -48,24 +48,67 @@ const GENERIC_SUFFIXES = [
|
|
|
48
48
|
{ at: 120_000, suffix: ' (2m — still running; Ctrl+C aborts the turn)' },
|
|
49
49
|
{ at: 300_000, suffix: ' (5m — likely stuck; Ctrl+C and check the VPN)' },
|
|
50
50
|
];
|
|
51
|
+
/**
|
|
52
|
+
* 2026-07-06 (audit-timeout) — the plan-chain audit's own liveness schedule.
|
|
53
|
+
*
|
|
54
|
+
* The audit is a PROXY model call (api.cspeach.dev over the internet), NOT a
|
|
55
|
+
* SAP tool call, so the generic schedule's "check the VPN" 5m copy is a WRONG
|
|
56
|
+
* diagnosis (the VPN is only for SAP). It is also now bounded: runPhaseAudit
|
|
57
|
+
* enforces a 120s hard deadline PER attempt × 3 attempts, so the whole audit
|
|
58
|
+
* can never exceed ~6m before it returns infra_failed — no infinite hang. This
|
|
59
|
+
* schedule reflects that reality honestly: past 2m the audit is necessarily in
|
|
60
|
+
* a retry (a single attempt hard-stops at 120s), and there is nothing to check
|
|
61
|
+
* — it either recovers on the next attempt or returns infra_failed. The
|
|
62
|
+
* per-attempt transition ("[audit] attempt N of 3 (previous timed out)") is
|
|
63
|
+
* printed separately by plan-chain via runPhaseAudit's onRetry hook.
|
|
64
|
+
*/
|
|
65
|
+
export const AUDIT_HEARTBEAT_THRESHOLDS = [
|
|
66
|
+
{ at: 3_000, label: '[audit] …running' },
|
|
67
|
+
{ at: 10_000, label: '[audit] …running (10s)' },
|
|
68
|
+
{ at: 30_000, label: '[audit] …running (30s)' },
|
|
69
|
+
{ at: 60_000, label: '[audit] …running (60s — still running, large evidence can take a while)' },
|
|
70
|
+
{ at: 120_000, label: '[audit] …running (2m — still running; large evidence can take a while · Ctrl+C aborts the turn)' },
|
|
71
|
+
{ at: 240_000, label: '[audit] …running (4m — a prior attempt timed out; retrying · Ctrl+C aborts the turn)' },
|
|
72
|
+
{ at: 300_000, label: '[audit] …running (5m — nearly at the retry ceiling; will stop and re-audit on resume if it can\'t finish)' },
|
|
73
|
+
];
|
|
51
74
|
export function startThinkingHeartbeat(opts) {
|
|
52
75
|
const startedAt = Date.now();
|
|
53
76
|
let nextIdx = 0;
|
|
54
77
|
let stopped = false;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
// Precedence: an explicit `thresholds` schedule (audit) wins over the
|
|
79
|
+
// `label` generic-suffix path, which wins over the default LLM schedule.
|
|
80
|
+
const THRESHOLDS_MS = opts?.thresholds
|
|
81
|
+
? opts.thresholds
|
|
82
|
+
: opts?.label
|
|
83
|
+
? GENERIC_SUFFIXES.map((t) => ({ at: t.at, label: `${opts.label}${t.suffix}` }))
|
|
84
|
+
: LLM_THRESHOLDS;
|
|
85
|
+
/**
|
|
86
|
+
* Receives the styled label WITHOUT a newline. Fable review (2026-07-05,
|
|
87
|
+
* audit-reverify item 1): the newline used to be baked INSIDE the chalk
|
|
88
|
+
* argument — with color enabled (FORCE_COLOR / real terminals) chalk
|
|
89
|
+
* appends its closing style codes AFTER the embedded `\n`, so the sink
|
|
90
|
+
* branch's trailing-`\n` strip was a no-op and every tick rendered an
|
|
91
|
+
* extra blank line. The label is now styled alone and each branch owns
|
|
92
|
+
* its own termination: sink = none (the caller terminates), chunkEmitter /
|
|
93
|
+
* stdout = a plain `\n` appended after the styled text (byte-identical to
|
|
94
|
+
* before under no-color; under color the closing style code now correctly
|
|
95
|
+
* precedes the newline).
|
|
96
|
+
*/
|
|
58
97
|
const emitLine = (line) => {
|
|
59
|
-
if (opts?.
|
|
98
|
+
if (opts?.emit) {
|
|
99
|
+
// Custom sink (e.g. plan-chain's args.log) owns line termination.
|
|
100
|
+
opts.emit(line);
|
|
101
|
+
}
|
|
102
|
+
else if (opts?.chunkEmitter) {
|
|
60
103
|
// Phase A #4 — route through Ink's chunk pipeline. The Body
|
|
61
104
|
// component's reducer treats this like any other model chunk and
|
|
62
105
|
// appends it as a node, so the heartbeat lives inside the React
|
|
63
106
|
// render tree and survives subsequent renders.
|
|
64
|
-
opts.chunkEmitter.emit('chunk', line);
|
|
107
|
+
opts.chunkEmitter.emit('chunk', line + '\n');
|
|
65
108
|
}
|
|
66
109
|
else {
|
|
67
110
|
// Classic mode — direct stdout, plain newline-terminated line.
|
|
68
|
-
process.stdout.write(line);
|
|
111
|
+
process.stdout.write(line + '\n');
|
|
69
112
|
}
|
|
70
113
|
};
|
|
71
114
|
const tick = () => {
|
|
@@ -74,8 +117,21 @@ export function startThinkingHeartbeat(opts) {
|
|
|
74
117
|
const elapsed = Date.now() - startedAt;
|
|
75
118
|
while (nextIdx < THRESHOLDS_MS.length && THRESHOLDS_MS[nextIdx].at <= elapsed) {
|
|
76
119
|
const t = THRESHOLDS_MS[nextIdx];
|
|
77
|
-
//
|
|
78
|
-
|
|
120
|
+
// audit-lean FIX #2 — a dynamic suffix (e.g. the live token count),
|
|
121
|
+
// evaluated NOW so a stall shows a different number from progress.
|
|
122
|
+
// Defensive: observability must never break the liveness tick.
|
|
123
|
+
let suffix = '';
|
|
124
|
+
if (opts?.suffix) {
|
|
125
|
+
try {
|
|
126
|
+
suffix = opts.suffix();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
suffix = '';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Indent by 2 spaces to align with tool-call rows. Style ONLY the
|
|
133
|
+
// label (+ suffix) — never a newline inside the chalk call (see emitLine).
|
|
134
|
+
emitLine(chalk.dim(` ${t.label}${suffix}`));
|
|
79
135
|
nextIdx++;
|
|
80
136
|
}
|
|
81
137
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* todo_set transcript block — UX Wave 2 follow-up (CC-style checklist).
|
|
3
|
+
*
|
|
4
|
+
* Owner feedback (2026-07): "it's not like we have in Claude Code — only
|
|
5
|
+
* shows the phase it's working on with an arrow." The turn-status strip
|
|
6
|
+
* (single ▶ row) and the Ctrl+T panel are both by-design; what was missing
|
|
7
|
+
* was Claude Code's behaviour of dropping the FULL checklist into the
|
|
8
|
+
* scrollback transcript on every todo_set, so the user sees the whole plan
|
|
9
|
+
* advance in place rather than a raw `⏺ todo_set({...})` JSON line.
|
|
10
|
+
*
|
|
11
|
+
* This module is the pure formatter for that block. todo.ts emits the
|
|
12
|
+
* returned string as a `chunk` on ctx.chunkEmitter after a successful set
|
|
13
|
+
* (routes to Ink Static scrollback AND the classic 'chunk'→stdout path);
|
|
14
|
+
* the raw ⏺/⎿ todo_set rows are widget-suppressed (tool-widget.ts) so the
|
|
15
|
+
* block is the sole visible representation — exactly mirroring ask_question.
|
|
16
|
+
*
|
|
17
|
+
* House style follows todo-panel.tsx glyphs precisely so the transcript
|
|
18
|
+
* block and the Ctrl+T panel read as the same list:
|
|
19
|
+
* ▣ completed (dim)
|
|
20
|
+
* ▶ in_progress (peach, bold)
|
|
21
|
+
* □ pending (default)
|
|
22
|
+
* Every row is truncate-end (Wave-1 rule — no soft-wrap), preceded by a
|
|
23
|
+
* slim `Tasks (done/total done)` header.
|
|
24
|
+
*/
|
|
25
|
+
import chalk from 'chalk';
|
|
26
|
+
import { PEACH } from './banners.js';
|
|
27
|
+
/** Max visible width of a row's TEXT (after the 2-char glyph+space prefix).
|
|
28
|
+
* 72-col total matches the Ctrl+T panel width; 70 leaves room for `▶ `. */
|
|
29
|
+
export const TODO_BLOCK_TEXT_MAX = 70;
|
|
30
|
+
function truncateEnd(s, max) {
|
|
31
|
+
return s.length > max ? s.substring(0, max - 1) + '…' : s;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Render the checklist as a scrollback block (leading + trailing newline for
|
|
35
|
+
* separation from surrounding transcript content). Returns ANSI-coloured
|
|
36
|
+
* text ready to emit as a `chunk`.
|
|
37
|
+
*/
|
|
38
|
+
export function formatTodoChecklistBlock(todos) {
|
|
39
|
+
const done = todos.filter((t) => t.status === 'completed').length;
|
|
40
|
+
const total = todos.length;
|
|
41
|
+
const header = chalk.dim(`Tasks (${done}/${total} done)`);
|
|
42
|
+
const rows = todos.map((t) => {
|
|
43
|
+
const text = truncateEnd(t.text, TODO_BLOCK_TEXT_MAX);
|
|
44
|
+
if (t.status === 'completed')
|
|
45
|
+
return chalk.dim(`▣ ${text}`);
|
|
46
|
+
if (t.status === 'in_progress')
|
|
47
|
+
return chalk.hex(PEACH).bold(`▶ ${text}`);
|
|
48
|
+
return `□ ${text}`;
|
|
49
|
+
});
|
|
50
|
+
return '\n' + [header, ...rows].join('\n') + '\n';
|
|
51
|
+
}
|
|
@@ -21,6 +21,31 @@ import { PEACH } from './banners.js';
|
|
|
21
21
|
* - No cyan, no rainbow, no boxes.
|
|
22
22
|
*/
|
|
23
23
|
const peach = (s) => chalk.hex(PEACH)(s);
|
|
24
|
+
/**
|
|
25
|
+
* Widget-representation de-clutter (2026-07): tools whose OWN rendering IS
|
|
26
|
+
* the visible representation, making the generic ⏺ dispatch line (with its
|
|
27
|
+
* raw JSON args) and the SUCCESS ⎿ result line pure scrollback noise. For
|
|
28
|
+
* these the ⏺ top line and the success ⎿ row are suppressed; ERROR ⎿ rows
|
|
29
|
+
* still render (the ✗ row is the only trace of a failed call). The agent
|
|
30
|
+
* loop also skips the tool spinner for these (it would orphan without its
|
|
31
|
+
* ⏺ line).
|
|
32
|
+
*
|
|
33
|
+
* Members:
|
|
34
|
+
* - ask_question: the Ink form / modal (or the classic console.log
|
|
35
|
+
* question text) is the representation. Classic mode is unaffected in
|
|
36
|
+
* the ways that matter — the v1 path prints its question via
|
|
37
|
+
* console.log and the v2 classic path prints its `form i/n · header`
|
|
38
|
+
* blocks, neither of which goes through these renderers.
|
|
39
|
+
* - todo_set: the handler emits a CC-style checklist block into the
|
|
40
|
+
* transcript (renderer/todo-block.ts) on every successful set — that
|
|
41
|
+
* block is the representation, so the raw `⏺ todo_set({"todos":[…])`
|
|
42
|
+
* line + ⎿ JSON result are suppressed. Owner feedback drove this: the
|
|
43
|
+
* transcript should show the full checklist, not a JSON tool line.
|
|
44
|
+
*/
|
|
45
|
+
const WIDGET_SUPPRESSED_TOOLS = new Set(['ask_question', 'todo_set']);
|
|
46
|
+
export function isWidgetSuppressedTool(name) {
|
|
47
|
+
return WIDGET_SUPPRESSED_TOOLS.has(name);
|
|
48
|
+
}
|
|
24
49
|
const ARG_SUMMARY_MAX = 64;
|
|
25
50
|
// 2026-05-15 (bug 1): widened from 96 → 240 so a parsed ADT exception
|
|
26
51
|
// message ("CTS_WBO_API/047: Request S4HK903388 is not a local request"
|
|
@@ -55,6 +80,10 @@ export function clearActiveSpinner() {
|
|
|
55
80
|
}
|
|
56
81
|
export function renderToolCallTop(params) {
|
|
57
82
|
const { name, args, chunkEmitter } = params;
|
|
83
|
+
// Ask-form de-clutter: the form/modal IS the visible representation —
|
|
84
|
+
// no ⏺ dispatch line (with its raw JSON args) belongs in scrollback.
|
|
85
|
+
if (isWidgetSuppressedTool(name))
|
|
86
|
+
return;
|
|
58
87
|
const write = (s) => {
|
|
59
88
|
if (chunkEmitter)
|
|
60
89
|
chunkEmitter.emit('chunk', s);
|
|
@@ -137,6 +166,14 @@ function startSpinnerWithVerbs(params, verbs, defaultVerb) {
|
|
|
137
166
|
}
|
|
138
167
|
export function renderToolCallBottom(params) {
|
|
139
168
|
const { durationMs, isError, resultSummary, chunkEmitter, name, args } = params;
|
|
169
|
+
// Ask-form de-clutter: paired with the top-line suppression — on SUCCESS
|
|
170
|
+
// the answer already came back through the form; a ⎿ row would be noise.
|
|
171
|
+
// ERRORS still render (review fix): an ask_form_invalid /
|
|
172
|
+
// ask_question_invalid round never mounts a form, so without the ✗ row
|
|
173
|
+
// the turn would stall silently with nothing in scrollback. D29 made the
|
|
174
|
+
// bottom row self-identifying (name + args), so no orphan risk.
|
|
175
|
+
if (name && isWidgetSuppressedTool(name) && !isError)
|
|
176
|
+
return;
|
|
140
177
|
const write = (s) => {
|
|
141
178
|
if (chunkEmitter)
|
|
142
179
|
chunkEmitter.emit('chunk', s);
|
|
@@ -59,18 +59,6 @@ export function disableBracketedPaste(stream = process.stdout) {
|
|
|
59
59
|
if (stream.isTTY)
|
|
60
60
|
stream.write(BRACKETED_PASTE_DISABLE);
|
|
61
61
|
}
|
|
62
|
-
/**
|
|
63
|
-
* Transform stream that collapses the content between `\x1b[200~` and
|
|
64
|
-
* `\x1b[201~` into a single line (newlines replaced with spaces). The
|
|
65
|
-
* markers themselves are stripped. All other bytes pass through
|
|
66
|
-
* unchanged, including keystrokes, arrow-key escape sequences, and
|
|
67
|
-
* terminal responses — these are not affected because they never contain
|
|
68
|
-
* the paste markers.
|
|
69
|
-
*
|
|
70
|
-
* The implementation uses Buffer operations (not string concat) to avoid
|
|
71
|
-
* splitting multi-byte UTF-8 sequences at chunk boundaries when a paste
|
|
72
|
-
* spans multiple `data` events.
|
|
73
|
-
*/
|
|
74
62
|
export class BracketedPasteDecoder extends Transform {
|
|
75
63
|
/**
|
|
76
64
|
* Readline with `terminal: true` inspects `input.isTTY` and calls
|
|
@@ -81,9 +69,17 @@ export class BracketedPasteDecoder extends Transform {
|
|
|
81
69
|
*
|
|
82
70
|
* We present as a TTY and forward the raw-mode toggle and the size
|
|
83
71
|
* getters to the real `process.stdin` / `process.stdout`.
|
|
72
|
+
*
|
|
73
|
+
* (Typed `boolean`, not the literal `true`, so InkStdinPasteGuard can
|
|
74
|
+
* mirror the real process.stdin.isTTY instead.)
|
|
84
75
|
*/
|
|
85
76
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
86
77
|
isTTY = true;
|
|
78
|
+
emitVerbatim;
|
|
79
|
+
constructor(options = {}) {
|
|
80
|
+
super();
|
|
81
|
+
this.emitVerbatim = options.emitVerbatim ?? false;
|
|
82
|
+
}
|
|
87
83
|
setRawMode(mode) {
|
|
88
84
|
if (typeof process.stdin.setRawMode === 'function')
|
|
89
85
|
process.stdin.setRawMode(mode);
|
|
@@ -166,9 +162,9 @@ export class BracketedPasteDecoder extends Transform {
|
|
|
166
162
|
this.carryTimer = null;
|
|
167
163
|
}
|
|
168
164
|
// End of stream. If we were still inside a paste (unterminated),
|
|
169
|
-
// flush the buffered content
|
|
165
|
+
// flush the buffered content so it's not lost.
|
|
170
166
|
if (this.inPaste && this.pasteBuf.length > 0) {
|
|
171
|
-
this.push(this.collapseNewlines(this.pasteBuf));
|
|
167
|
+
this.push(this.emitVerbatim ? this.pasteBuf : this.collapseNewlines(this.pasteBuf));
|
|
172
168
|
this.pasteBuf = Buffer.alloc(0);
|
|
173
169
|
this.inPaste = false;
|
|
174
170
|
}
|
|
@@ -192,11 +188,24 @@ export class BracketedPasteDecoder extends Transform {
|
|
|
192
188
|
return;
|
|
193
189
|
}
|
|
194
190
|
this.pasteBuf = Buffer.concat([this.pasteBuf, data.subarray(i, endIdx)]);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
191
|
+
if (this.emitVerbatim) {
|
|
192
|
+
// Ink mode: ONE chunk, newlines intact, markers stripped, no
|
|
193
|
+
// synthetic Enter. Downstream (Ink useInput → text-input paste
|
|
194
|
+
// branch) buffers the complete lines and keeps the fragment —
|
|
195
|
+
// nothing submits until the user presses Enter. Crucially a
|
|
196
|
+
// `\r` that arrived as its own stdin chunk INSIDE the span was
|
|
197
|
+
// accumulated here instead of reaching Ink's parseKeypress,
|
|
198
|
+
// which would have flagged it key.return and submitted.
|
|
199
|
+
if (this.pasteBuf.length > 0)
|
|
200
|
+
this.push(this.pasteBuf);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
this.push(this.collapseNewlines(this.pasteBuf));
|
|
204
|
+
// Append a newline so readline commits the pasted line as a
|
|
205
|
+
// single input — otherwise the user would still have to press
|
|
206
|
+
// Enter after the paste.
|
|
207
|
+
this.push(Buffer.from('\n'));
|
|
208
|
+
}
|
|
200
209
|
this.pasteBuf = Buffer.alloc(0);
|
|
201
210
|
this.inPaste = false;
|
|
202
211
|
i = endIdx + END_MARKER.length;
|
|
@@ -11,12 +11,17 @@ export const BUILTIN_ENTRIES = [
|
|
|
11
11
|
{ name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
|
|
12
12
|
{ name: '/cost', description: 'Show this session\'s API spend so far ($ + token breakdown)' },
|
|
13
13
|
{ name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
|
|
14
|
+
{ name: '/export', description: 'Export the signable session audit record — /export audit [filename]' },
|
|
15
|
+
{ name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
|
|
14
16
|
{ name: '/exit', description: 'Exit CSPeach' },
|
|
15
17
|
{ name: '/quit', description: 'Exit CSPeach (alias)' },
|
|
16
18
|
{ name: '/help', description: 'Show all skills and commands' },
|
|
17
19
|
{ name: '/skills', description: 'Show all skills (alias)' },
|
|
20
|
+
{ name: '/mode', description: 'Cycle or set the session write mode (advisory / gated / auto) — config unchanged' },
|
|
18
21
|
{ name: '/new', description: 'Reset routing — next prompt is classified afresh' },
|
|
22
|
+
{ name: '/recap', description: 'Show the session recap (last session · open tasks · plan progress)' },
|
|
19
23
|
{ name: '/reset', description: 'Reset routing (alias)' },
|
|
24
|
+
{ name: '/rewind', description: 'Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)' },
|
|
20
25
|
{ name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
|
|
21
26
|
{ name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
|
|
22
27
|
{ name: '/transport', description: 'View or set the active transport for this session' },
|