taskplane 0.26.1 → 0.28.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 +4 -1
- package/bin/taskplane.mjs +12 -5
- package/dashboard/public/app.js +162 -13
- package/extensions/taskplane/abort.ts +2 -1
- package/extensions/taskplane/agent-host.ts +100 -1
- package/extensions/taskplane/cleanup.ts +272 -10
- package/extensions/taskplane/discovery.ts +1818 -1508
- package/extensions/taskplane/engine.ts +182 -47
- package/extensions/taskplane/execution.ts +172 -51
- package/extensions/taskplane/extension.ts +5125 -5125
- package/extensions/taskplane/formatting.ts +70 -11
- package/extensions/taskplane/git.ts +34 -0
- package/extensions/taskplane/lane-runner.ts +586 -46
- package/extensions/taskplane/merge.ts +3128 -2917
- package/extensions/taskplane/persistence.ts +3 -0
- package/extensions/taskplane/resume.ts +86 -30
- package/extensions/taskplane/supervisor-primer.md +55 -0
- package/extensions/taskplane/types.ts +52 -3
- package/package.json +1 -1
- package/skills/create-taskplane-task/SKILL.md +58 -0
- package/skills/create-taskplane-task/references/prompt-template.md +39 -0
- package/templates/agents/task-worker-segment.md +44 -0
- package/templates/agents/task-worker.md +429 -387
package/README.md
CHANGED
|
@@ -71,14 +71,17 @@ pi install -l npm:taskplane
|
|
|
71
71
|
## Quickstart
|
|
72
72
|
|
|
73
73
|
### 1. Initialize a project (to scaffold settings)
|
|
74
|
+
This step assumes you have installed Taskplane using one of the above options.
|
|
75
|
+
|
|
74
76
|
(NOTE: if 'my-project' is a monorepo, be sure to run git init first. Taskplane uses git worktrees to isolate agent coding until you're ready to merge back to your default branch.)
|
|
77
|
+
|
|
75
78
|
```bash
|
|
76
79
|
cd my-project
|
|
77
80
|
taskplane init
|
|
78
81
|
```
|
|
79
82
|
You'll answer a few questions. You can usually just accept the defaults.
|
|
80
83
|
|
|
81
|
-
This creates config files in `.pi/`, agent prompts, two example tasks, and adds `.gitignore` entries for runtime artifacts. On first install, init bootstraps global preferences at `~/.pi/agent/taskplane/preferences.json` with thinking defaults set to `high` for worker & reviewer, and off for merger. Init auto-detects whether you're in a
|
|
84
|
+
This creates: config files in `.pi/`, agent prompts, two example tasks, and adds `.gitignore` entries for runtime artifacts. On first install, init bootstraps global preferences at `~/.pi/agent/taskplane/preferences.json` with thinking defaults set to `high` for worker & reviewer, and off for merger. Init auto-detects whether you're in a monorepo or a polyrepo workspace. See the [install tutorial](docs/tutorials/install.md) for workspace mode and other scenarios.
|
|
82
85
|
|
|
83
86
|
Already have a task folder (for example `docs/task-management`)? Use:
|
|
84
87
|
|
package/bin/taskplane.mjs
CHANGED
|
@@ -787,18 +787,24 @@ export async function collectInitAgentConfig({
|
|
|
787
787
|
return initAgentConfig;
|
|
788
788
|
}
|
|
789
789
|
|
|
790
|
+
/** Normalize a path string to forward slashes (for config file output). */
|
|
791
|
+
function fwdSlash(p) {
|
|
792
|
+
return typeof p === "string" ? p.replace(/\\/g, "/") : p;
|
|
793
|
+
}
|
|
794
|
+
|
|
790
795
|
export function generateProjectConfig(vars, _initAgentConfig = null) {
|
|
796
|
+
const tasksRoot = fwdSlash(vars.tasks_root);
|
|
791
797
|
const projectConfig = {
|
|
792
798
|
configVersion: 1,
|
|
793
799
|
taskRunner: {
|
|
794
800
|
project: { name: vars.project_name, description: "" },
|
|
795
|
-
paths: { tasks:
|
|
801
|
+
paths: { tasks: tasksRoot },
|
|
796
802
|
testing: { commands: buildTestingCommands(vars) },
|
|
797
803
|
taskAreas: {
|
|
798
804
|
[vars.default_area]: {
|
|
799
|
-
path:
|
|
805
|
+
path: tasksRoot,
|
|
800
806
|
prefix: vars.default_prefix,
|
|
801
|
-
context: `${
|
|
807
|
+
context: `${tasksRoot}/CONTEXT.md`,
|
|
802
808
|
},
|
|
803
809
|
},
|
|
804
810
|
},
|
|
@@ -820,10 +826,11 @@ export function generateProjectConfig(vars, _initAgentConfig = null) {
|
|
|
820
826
|
}
|
|
821
827
|
|
|
822
828
|
function generateWorkspaceYaml(repoNames, defaultRepo, tasksRoot) {
|
|
829
|
+
const normalizedTasksRoot = fwdSlash(tasksRoot);
|
|
823
830
|
const reposBlock = repoNames
|
|
824
831
|
.map((name) => ` ${name}:\n path: "${name}"`)
|
|
825
832
|
.join("\n");
|
|
826
|
-
return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${
|
|
833
|
+
return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${normalizedTasksRoot}"\n default_repo: "${defaultRepo}"\n task_packet_repo: "${defaultRepo}"\n`;
|
|
827
834
|
}
|
|
828
835
|
|
|
829
836
|
function readWorkspaceJson(configRepoRoot) {
|
|
@@ -1923,7 +1930,7 @@ async function cmdInit(args) {
|
|
|
1923
1930
|
default_branch: "main",
|
|
1924
1931
|
})),
|
|
1925
1932
|
routing: {
|
|
1926
|
-
tasks_root: vars.tasks_root,
|
|
1933
|
+
tasks_root: fwdSlash(vars.tasks_root),
|
|
1927
1934
|
default_repo: configRepoName,
|
|
1928
1935
|
strict: false,
|
|
1929
1936
|
},
|
package/dashboard/public/app.js
CHANGED
|
@@ -699,21 +699,30 @@ function renderLanesTasks(batch, sessions) {
|
|
|
699
699
|
const showPacketHome = !!packetHomeRepo && packetHomeRepo !== (tRepo || lane.repoId || "");
|
|
700
700
|
|
|
701
701
|
// Progress cell
|
|
702
|
+
// TP-174: Prefer V2 snapshot progress (segment-scoped when available)
|
|
703
|
+
// over full STATUS.md counts when the task is actively running on this lane.
|
|
704
|
+
// TP-176: Succeeded tasks always show 100% regardless of sidecar/statusData (#491).
|
|
702
705
|
let progressHtml = "";
|
|
703
|
-
|
|
704
|
-
|
|
706
|
+
const v2p = ls && ls._v2Progress;
|
|
707
|
+
const useV2 = v2p && v2p.total > 0 && ls.taskId === task.taskId;
|
|
708
|
+
if (task.status === "succeeded") {
|
|
709
|
+
// #491 fix: succeeded tasks always show 100%
|
|
705
710
|
progressHtml = `
|
|
706
711
|
<div class="task-progress">
|
|
707
|
-
<div class="task-progress-bar">
|
|
708
|
-
|
|
709
|
-
</div>
|
|
710
|
-
<span class="task-progress-text">${sd.progress}% ${sd.checked}/${sd.total}</span>
|
|
712
|
+
<div class="task-progress-bar"><div class="task-progress-fill pct-hi" style="width:100%"></div></div>
|
|
713
|
+
<span class="task-progress-text">100%</span>
|
|
711
714
|
</div>`;
|
|
712
|
-
} else if (
|
|
715
|
+
} else if (sd || useV2) {
|
|
716
|
+
const displayChecked = useV2 ? v2p.checked : (sd ? sd.checked : 0);
|
|
717
|
+
const displayTotal = useV2 ? v2p.total : (sd ? sd.total : 0);
|
|
718
|
+
const displayProgress = displayTotal > 0 ? Math.round((displayChecked / displayTotal) * 100) : 0;
|
|
719
|
+
const fillClass = pctClass(displayProgress);
|
|
713
720
|
progressHtml = `
|
|
714
721
|
<div class="task-progress">
|
|
715
|
-
<div class="task-progress-bar"
|
|
716
|
-
|
|
722
|
+
<div class="task-progress-bar">
|
|
723
|
+
<div class="task-progress-fill ${fillClass}" style="width:${displayProgress}%"></div>
|
|
724
|
+
</div>
|
|
725
|
+
<span class="task-progress-text">${displayProgress}% ${displayChecked}/${displayTotal}</span>
|
|
717
726
|
</div>`;
|
|
718
727
|
} else if (task.status === "pending") {
|
|
719
728
|
progressHtml = `
|
|
@@ -1881,6 +1890,127 @@ function renderV2Event(evt) {
|
|
|
1881
1890
|
}
|
|
1882
1891
|
}
|
|
1883
1892
|
|
|
1893
|
+
// ── Segment-Scoped STATUS.md Helpers (TP-176) ──────────────────────────────
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* Resolve the active segment repoId for a given task.
|
|
1897
|
+
* Uses runtimeLaneSnapshots (active segment) and falls back to
|
|
1898
|
+
* taskSegmentProgress (batch state).
|
|
1899
|
+
* Returns { repoId, segmentInfo } or null if single-segment / unresolvable.
|
|
1900
|
+
*/
|
|
1901
|
+
function resolveActiveSegmentForTask(taskId) {
|
|
1902
|
+
if (!currentData) return null;
|
|
1903
|
+
const batch = currentData.batch;
|
|
1904
|
+
if (!batch) return null;
|
|
1905
|
+
const task = (batch.tasks || []).find(t => t.taskId === taskId);
|
|
1906
|
+
if (!task) return null;
|
|
1907
|
+
const segmentIds = Array.isArray(task.segmentIds) ? task.segmentIds.filter(id => typeof id === "string") : [];
|
|
1908
|
+
if (segmentIds.length <= 1) return null; // single-segment or no segments
|
|
1909
|
+
|
|
1910
|
+
// Try to get active segment from runtime lane snapshots
|
|
1911
|
+
const v2Snapshots = currentData.runtimeLaneSnapshots || {};
|
|
1912
|
+
for (const snap of Object.values(v2Snapshots)) {
|
|
1913
|
+
if (snap && snap.taskId === taskId && snap.segmentId) {
|
|
1914
|
+
const parsed = parseSegmentId(snap.segmentId);
|
|
1915
|
+
if (parsed) {
|
|
1916
|
+
const idx = segmentIds.indexOf(snap.segmentId);
|
|
1917
|
+
return {
|
|
1918
|
+
repoId: parsed.repoId,
|
|
1919
|
+
segmentInfo: {
|
|
1920
|
+
index: idx >= 0 ? idx + 1 : null,
|
|
1921
|
+
total: segmentIds.length,
|
|
1922
|
+
repoId: parsed.repoId,
|
|
1923
|
+
segmentId: snap.segmentId,
|
|
1924
|
+
},
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// Fallback: use taskSegmentProgress (batch state)
|
|
1931
|
+
const segmentStatusMap = buildSegmentStatusMap(batch);
|
|
1932
|
+
const info = taskSegmentProgress(task, segmentStatusMap, null);
|
|
1933
|
+
if (info && info.repoId) {
|
|
1934
|
+
return { repoId: info.repoId, segmentInfo: info };
|
|
1935
|
+
}
|
|
1936
|
+
return null;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* Filter STATUS.md content to show only the active segment's blocks.
|
|
1941
|
+
* Within each `### Step N:` section, removes `#### Segment: <otherRepo>` blocks
|
|
1942
|
+
* and keeps only the block matching `activeRepoId`.
|
|
1943
|
+
* Non-step content (metadata, notes, reviews, etc.) is preserved.
|
|
1944
|
+
*
|
|
1945
|
+
* Returns the filtered markdown string, or the original if no segment markers found.
|
|
1946
|
+
*/
|
|
1947
|
+
function filterStatusMdForSegment(markdown, activeRepoId) {
|
|
1948
|
+
if (!activeRepoId) return markdown;
|
|
1949
|
+
const lines = markdown.split('\n');
|
|
1950
|
+
const result = [];
|
|
1951
|
+
let inStep = false; // inside a ### Step section
|
|
1952
|
+
let inSegmentBlock = false; // inside a #### Segment: <repo> block
|
|
1953
|
+
let segmentMatch = false; // current segment block matches active repo
|
|
1954
|
+
let foundAnySegmentHeader = false;
|
|
1955
|
+
|
|
1956
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1957
|
+
const line = lines[i];
|
|
1958
|
+
|
|
1959
|
+
// Detect step headers: ### Step N: ...
|
|
1960
|
+
if (/^###\s+Step\s+\d+/.test(line)) {
|
|
1961
|
+
inStep = true;
|
|
1962
|
+
inSegmentBlock = false;
|
|
1963
|
+
segmentMatch = false;
|
|
1964
|
+
result.push(line);
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// Detect non-step ### headers (e.g., ### Reviews, ### Notes)
|
|
1969
|
+
if (/^###\s+/.test(line) && !/^###\s+Step\s+\d+/.test(line)) {
|
|
1970
|
+
inStep = false;
|
|
1971
|
+
inSegmentBlock = false;
|
|
1972
|
+
segmentMatch = false;
|
|
1973
|
+
result.push(line);
|
|
1974
|
+
continue;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Inside a step section, detect #### Segment: <repoId> headers
|
|
1978
|
+
if (inStep && /^####\s+Segment:\s*/.test(line)) {
|
|
1979
|
+
foundAnySegmentHeader = true;
|
|
1980
|
+
const segRepo = line.replace(/^####\s+Segment:\s*/, '').trim();
|
|
1981
|
+
inSegmentBlock = true;
|
|
1982
|
+
segmentMatch = (segRepo === activeRepoId);
|
|
1983
|
+
if (segmentMatch) {
|
|
1984
|
+
result.push(line);
|
|
1985
|
+
}
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Detect any other #### header (ends current segment block)
|
|
1990
|
+
if (/^####\s+/.test(line)) {
|
|
1991
|
+
inSegmentBlock = false;
|
|
1992
|
+
segmentMatch = false;
|
|
1993
|
+
result.push(line);
|
|
1994
|
+
continue;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// If we're in a segment block, only include matching lines
|
|
1998
|
+
if (inSegmentBlock) {
|
|
1999
|
+
if (segmentMatch) {
|
|
2000
|
+
result.push(line);
|
|
2001
|
+
}
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// Outside segment blocks: keep the line
|
|
2006
|
+
result.push(line);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// If no segment headers were found, return original (fallback for single-segment)
|
|
2010
|
+
if (!foundAnySegmentHeader) return markdown;
|
|
2011
|
+
return result.join('\n');
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1884
2014
|
// ── Open STATUS.md viewer ───────────────────────────────────────────────────
|
|
1885
2015
|
|
|
1886
2016
|
function viewStatusMd(taskId) {
|
|
@@ -1897,7 +2027,15 @@ function viewStatusMd(taskId) {
|
|
|
1897
2027
|
autoScrollOn = false;
|
|
1898
2028
|
lastStatusMdText = '';
|
|
1899
2029
|
|
|
1900
|
-
|
|
2030
|
+
// TP-176: Include segment context in title for multi-segment tasks
|
|
2031
|
+
const segData = resolveActiveSegmentForTask(taskId);
|
|
2032
|
+
if (segData && segData.segmentInfo) {
|
|
2033
|
+
const label = segmentProgressText(segData.segmentInfo);
|
|
2034
|
+
$terminalTitle.textContent = `STATUS.md — ${taskId} · ${label}`;
|
|
2035
|
+
} else {
|
|
2036
|
+
$terminalTitle.textContent = `STATUS.md — ${taskId}`;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1901
2039
|
$autoScrollText.textContent = 'Track progress';
|
|
1902
2040
|
$autoScrollCheckbox.checked = false;
|
|
1903
2041
|
$terminalPanel.style.display = '';
|
|
@@ -1916,11 +2054,22 @@ function pollStatusMd() {
|
|
|
1916
2054
|
return r.text();
|
|
1917
2055
|
})
|
|
1918
2056
|
.then(text => {
|
|
2057
|
+
// TP-176: Apply segment-scoped filtering for multi-segment tasks.
|
|
2058
|
+
// Re-resolve on each poll since the active segment may change.
|
|
2059
|
+
const segData = resolveActiveSegmentForTask(viewerTarget);
|
|
2060
|
+
let displayText = text;
|
|
2061
|
+
if (segData && segData.repoId) {
|
|
2062
|
+
displayText = filterStatusMdForSegment(text, segData.repoId);
|
|
2063
|
+
// Update title with current segment context (may change between polls)
|
|
2064
|
+
const label = segmentProgressText(segData.segmentInfo);
|
|
2065
|
+
$terminalTitle.textContent = `STATUS.md \u2014 ${viewerTarget} \u00b7 ${label}`;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1919
2068
|
// Diff-and-skip: no change, no DOM update
|
|
1920
|
-
if (
|
|
1921
|
-
lastStatusMdText =
|
|
2069
|
+
if (displayText === lastStatusMdText) return;
|
|
2070
|
+
lastStatusMdText = displayText;
|
|
1922
2071
|
|
|
1923
|
-
const { html, hasLastChecked } = renderStatusMd(
|
|
2072
|
+
const { html, hasLastChecked } = renderStatusMd(displayText);
|
|
1924
2073
|
$terminalBody.innerHTML = html;
|
|
1925
2074
|
|
|
1926
2075
|
// Update tracking highlight
|
|
@@ -85,7 +85,8 @@ export function selectAbortTargetSessions(
|
|
|
85
85
|
laneId: lane.laneId,
|
|
86
86
|
taskId: currentTask?.taskId || null,
|
|
87
87
|
worktreePath: lane.worktreePath,
|
|
88
|
-
|
|
88
|
+
// TP-169: Guard against null task stubs from reconstructAllocatedLanes
|
|
89
|
+
taskFolder: currentTask?.task?.taskFolder || null,
|
|
89
90
|
});
|
|
90
91
|
}
|
|
91
92
|
|
|
@@ -136,6 +136,23 @@ export interface AgentHostOptions {
|
|
|
136
136
|
packet?: PacketPaths | null;
|
|
137
137
|
/** Extra environment variables for the child process */
|
|
138
138
|
env?: Record<string, string>;
|
|
139
|
+
/**
|
|
140
|
+
* Callback invoked when agent_end fires, before stdin is closed.
|
|
141
|
+
* Receives the last assistant message text.
|
|
142
|
+
* Return a string to send as a new prompt (re-prompt the agent),
|
|
143
|
+
* or null to close the session normally.
|
|
144
|
+
*
|
|
145
|
+
* @since TP-172
|
|
146
|
+
*/
|
|
147
|
+
onPrematureExit?: (assistantMessage: string) => Promise<string | null>;
|
|
148
|
+
/**
|
|
149
|
+
* Maximum number of exit interceptions before forcing session close.
|
|
150
|
+
* Prevents infinite loops where the callback always returns a new prompt.
|
|
151
|
+
* Default: 2
|
|
152
|
+
*
|
|
153
|
+
* @since TP-172
|
|
154
|
+
*/
|
|
155
|
+
maxExitInterceptions?: number;
|
|
139
156
|
}
|
|
140
157
|
|
|
141
158
|
/**
|
|
@@ -235,6 +252,7 @@ export function spawnAgent(
|
|
|
235
252
|
const cliPath = resolvePiCliPath();
|
|
236
253
|
const closeDelayMs = opts.closeDelayMs ?? 100;
|
|
237
254
|
const timeoutMs = opts.timeoutMs ?? 0;
|
|
255
|
+
const maxExitInterceptions = opts.maxExitInterceptions ?? 3;
|
|
238
256
|
|
|
239
257
|
// Build Pi CLI arguments
|
|
240
258
|
const piArgs: string[] = [cliPath, "--mode", "rpc", "--no-session"];
|
|
@@ -275,6 +293,12 @@ export function spawnAgent(
|
|
|
275
293
|
let contextUsage: AgentHostResult["contextUsage"] = null;
|
|
276
294
|
let stderrBuffer = "";
|
|
277
295
|
const STDERR_MAX = 2048;
|
|
296
|
+
/** Last assistant message text captured from message_end events (TP-172) */
|
|
297
|
+
let lastAssistantMessage = "";
|
|
298
|
+
/** Number of times exit interception has occurred (TP-172) */
|
|
299
|
+
let exitInterceptionCount = 0;
|
|
300
|
+
/** Whether the current turn had any tool calls (TP-172: text-only gate) */
|
|
301
|
+
let currentTurnHadToolCalls = false;
|
|
278
302
|
|
|
279
303
|
// Timeout
|
|
280
304
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -538,6 +562,8 @@ export function spawnAgent(
|
|
|
538
562
|
const content = extractAssistantText(event.message);
|
|
539
563
|
if (content) {
|
|
540
564
|
emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
|
|
565
|
+
// TP-172: Track last assistant message for exit interception
|
|
566
|
+
lastAssistantMessage = content;
|
|
541
567
|
}
|
|
542
568
|
}
|
|
543
569
|
// Request session stats immediately on first assistant message,
|
|
@@ -560,6 +586,7 @@ export function spawnAgent(
|
|
|
560
586
|
}
|
|
561
587
|
case "tool_execution_start": {
|
|
562
588
|
toolCalls++;
|
|
589
|
+
currentTurnHadToolCalls = true;
|
|
563
590
|
const toolName = event.toolName || "tool";
|
|
564
591
|
const argPreview = typeof event.args === "string" ? event.args.slice(0, 300) :
|
|
565
592
|
(event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 300) : "");
|
|
@@ -602,7 +629,79 @@ export function spawnAgent(
|
|
|
602
629
|
}
|
|
603
630
|
case "agent_end": {
|
|
604
631
|
agentEnded = true;
|
|
605
|
-
|
|
632
|
+
// TP-172: Exit interception — intercept any exit when callback
|
|
633
|
+
// is provided and under limit. The callback (lane-runner) decides
|
|
634
|
+
// whether the worker made progress. We don't gate on tool calls
|
|
635
|
+
// because workers commonly use tools (reads/greps) then exit
|
|
636
|
+
// with a text declaration ("Now let me fix this:") without
|
|
637
|
+
// actually making the edit.
|
|
638
|
+
const shouldIntercept = opts.onPrematureExit
|
|
639
|
+
&& exitInterceptionCount < maxExitInterceptions;
|
|
640
|
+
if (shouldIntercept) {
|
|
641
|
+
exitInterceptionCount++;
|
|
642
|
+
const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout
|
|
643
|
+
// Wrap in Promise.resolve().then() to catch synchronous throws
|
|
644
|
+
const interceptPromise = Promise.resolve().then(() =>
|
|
645
|
+
opts.onPrematureExit!(lastAssistantMessage));
|
|
646
|
+
const timeoutPromise = new Promise<null>((res) =>
|
|
647
|
+
setTimeout(() => res(null), INTERCEPTION_TIMEOUT_MS));
|
|
648
|
+
Promise.race([interceptPromise, timeoutPromise])
|
|
649
|
+
.then(
|
|
650
|
+
(newPrompt: string | null) => {
|
|
651
|
+
if (newPrompt && !stdinClosed && proc.stdin && !proc.stdin.destroyed) {
|
|
652
|
+
// Re-prompt the agent with supervisor guidance
|
|
653
|
+
agentEnded = false; // Reset for the new turn
|
|
654
|
+
currentTurnHadToolCalls = false; // Reset for new turn
|
|
655
|
+
proc.stdin.write(JSON.stringify({ type: "prompt", message: newPrompt }) + "\n");
|
|
656
|
+
emitEvent("exit_intercepted", {
|
|
657
|
+
interceptionCount: exitInterceptionCount,
|
|
658
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
659
|
+
supervisorConsulted: true,
|
|
660
|
+
action: "reprompt",
|
|
661
|
+
newPromptPreview: truncatePayload(newPrompt, MAX_CONV_PAYLOAD_CHARS),
|
|
662
|
+
});
|
|
663
|
+
} else {
|
|
664
|
+
// Callback returned null or stdin already closed — close session
|
|
665
|
+
const reason = stdinClosed ? "stdin_closed"
|
|
666
|
+
: newPrompt === null ? "callback_returned_null"
|
|
667
|
+
: "unknown";
|
|
668
|
+
emitEvent("exit_intercepted", {
|
|
669
|
+
interceptionCount: exitInterceptionCount,
|
|
670
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
671
|
+
supervisorConsulted: true,
|
|
672
|
+
action: "close",
|
|
673
|
+
reason,
|
|
674
|
+
});
|
|
675
|
+
closeStdin();
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
(err: unknown) => {
|
|
679
|
+
// Callback rejected — emit single diagnostic event and close
|
|
680
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
681
|
+
emitEvent("exit_intercepted", {
|
|
682
|
+
interceptionCount: exitInterceptionCount,
|
|
683
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
684
|
+
supervisorConsulted: false,
|
|
685
|
+
action: "close",
|
|
686
|
+
reason: "callback_error",
|
|
687
|
+
error: msg,
|
|
688
|
+
});
|
|
689
|
+
closeStdin();
|
|
690
|
+
},
|
|
691
|
+
);
|
|
692
|
+
} else {
|
|
693
|
+
// No callback, had tool calls, or interception limit reached — close normally
|
|
694
|
+
if (opts.onPrematureExit && exitInterceptionCount >= maxExitInterceptions) {
|
|
695
|
+
emitEvent("exit_intercepted", {
|
|
696
|
+
interceptionCount: exitInterceptionCount,
|
|
697
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
698
|
+
supervisorConsulted: false,
|
|
699
|
+
action: "close",
|
|
700
|
+
reason: "max_interceptions_reached",
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
closeStdin();
|
|
704
|
+
}
|
|
606
705
|
break;
|
|
607
706
|
}
|
|
608
707
|
}
|