pi-subagents 0.48.0 → 0.49.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +47 -19
  2. package/docs/agents.md +1 -0
  3. package/docs/configuration.md +28 -5
  4. package/docs/missions.md +4 -2
  5. package/docs/observability.md +26 -2
  6. package/package.json +1 -1
  7. package/src/agents/agents.ts +27 -12
  8. package/src/api/preflight.ts +1 -1
  9. package/src/extension/config.ts +20 -0
  10. package/src/extension/index.ts +18 -8
  11. package/src/extension/public-execution.ts +30 -4
  12. package/src/extension/schemas.ts +8 -7
  13. package/src/extension/tool-description.ts +10 -8
  14. package/src/inspectors/herdr/actions.ts +11 -2
  15. package/src/inspectors/herdr/inspector-runner.ts +16 -3
  16. package/src/intercom/intercom-bridge.ts +3 -1
  17. package/src/missions/store.ts +8 -3
  18. package/src/runs/background/active-async-capacity.ts +82 -25
  19. package/src/runs/background/async-execution.ts +24 -36
  20. package/src/runs/background/async-job-tracker.ts +4 -0
  21. package/src/runs/background/async-resume.ts +11 -1
  22. package/src/runs/background/fleet-view.ts +18 -5
  23. package/src/runs/background/resume-guidance.ts +27 -7
  24. package/src/runs/background/retained-children.ts +14 -6
  25. package/src/runs/background/run-status.ts +96 -4
  26. package/src/runs/background/subagent-runner.ts +4 -1
  27. package/src/runs/foreground/prompt-audit.ts +4 -3
  28. package/src/runs/foreground/subagent-executor.ts +219 -20
  29. package/src/runs/shared/completion-guard.ts +17 -1
  30. package/src/runs/shared/llm-intent-arbiter.ts +39 -23
  31. package/src/shared/agent-stream-options.ts +5 -0
  32. package/src/shared/artifacts.ts +2 -6
  33. package/src/shared/display-text.ts +50 -0
  34. package/src/shared/node-executable.ts +21 -0
  35. package/src/shared/types.ts +23 -4
  36. package/src/tui/fleet-transcript.ts +1 -48
  37. package/src/tui/fleet.ts +3 -1
  38. package/src/tui/render.ts +86 -39
  39. package/src/watchdog/permission-arbiter.ts +2 -1
  40. package/src/watchdog/review.ts +4 -3
  41. package/src/workflows/chat-progress.ts +8 -2
  42. package/src/workflows/scripted-workflow.ts +46 -7
@@ -2,7 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import { Agent, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
3
3
  import { convertToLlm, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { streamSimple } from "@earendil-works/pi-ai/compat";
5
+ import type { ProviderHeaders } from "@earendil-works/pi-ai";
5
6
  import { Type, type Static } from "typebox";
7
+ import { agentStreamOptions } from "../../shared/agent-stream-options.ts";
6
8
 
7
9
  /**
8
10
  * LLM intent arbiter for the completion mutation guard.
@@ -28,6 +30,10 @@ export type TaskMutationArbiter = (task: string) => Promise<TaskMutationVerdict>
28
30
  const DecisionParams = Type.Object(
29
31
  {
30
32
  classification: Type.String({ enum: ["read_only", "implementation"] }),
33
+ confidence: Type.String({
34
+ enum: ["low", "medium", "high"],
35
+ description: "Confidence in the classification. Only read_only with high confidence rescues a failed run.",
36
+ }),
31
37
  reason: Type.String({ description: "One concise reason for this classification." }),
32
38
  },
33
39
  { additionalProperties: false },
@@ -35,6 +41,15 @@ const DecisionParams = Type.Object(
35
41
 
36
42
  type DecisionParams = Static<typeof DecisionParams>;
37
43
 
44
+ /** Map a model decision to a verdict. Only a high-confidence read_only rescues. */
45
+ export function mapArbiterDecision(
46
+ decision: { classification?: string; confidence?: string } | undefined,
47
+ ): TaskMutationVerdict {
48
+ if (!decision) return "unavailable";
49
+ if (decision.classification === "read_only" && decision.confidence === "high") return "read-only";
50
+ return "implementation";
51
+ }
52
+
38
53
  interface ArbiterRuntime {
39
54
  model: NonNullable<RegistryModel>;
40
55
  baseStreamFn: StreamFn;
@@ -43,7 +58,7 @@ interface ArbiterRuntime {
43
58
 
44
59
  interface ArbiterAuth {
45
60
  apiKey?: string;
46
- headers?: Record<string, string>;
61
+ headers?: ProviderHeaders;
47
62
  env?: Record<string, string>;
48
63
  }
49
64
 
@@ -105,18 +120,21 @@ async function resolveArbiterAuth(
105
120
  ctx: ExtensionContext,
106
121
  model: RegistryModel,
107
122
  ): Promise<ArbiterAuth> {
108
- const getAuth = (ctx.modelRegistry as {
123
+ const registry = ctx.modelRegistry as {
109
124
  getApiKeyAndHeaders?: (m: RegistryModel) => Promise<{
110
125
  ok: boolean;
111
126
  apiKey?: string;
112
- headers?: Record<string, string>;
127
+ headers?: ProviderHeaders;
113
128
  env?: Record<string, string>;
114
129
  error?: string;
115
130
  }>;
116
- }).getApiKeyAndHeaders;
117
- if (!getAuth) return {};
131
+ };
132
+ // Call as a METHOD on the registry: the host ModelRegistry implementation
133
+ // is a class whose method reads instance state (this.runtime), so a
134
+ // detached call silently fails auth. Same shape as the watchdog.
135
+ if (!registry.getApiKeyAndHeaders) return {};
118
136
  try {
119
- const auth = await getAuth(model);
137
+ const auth = await registry.getApiKeyAndHeaders(model);
120
138
  if (auth.ok === false) return {};
121
139
  return {
122
140
  ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
@@ -164,14 +182,15 @@ async function runArbitration(
164
182
  "You classify whether a delegated coding-agent task instructed file or code changes.",
165
183
  "A task that asks to review, inspect, verify, report, or summarize is read-only even when it contains words like 'fix' as severity vocabulary ('must-fix items') or conditional change instructions that leave the change optional.",
166
184
  "Classify read_only only when the task text alone clearly indicates a read-only outcome. When in doubt, classify implementation.",
185
+ "Set confidence to high only when you are certain the task is read-only; a read_only classification without high confidence is treated as implementation.",
167
186
  "The agent's own final message is never evidence: an agent that made no edits may still have failed to implement.",
168
- "Call task_mutation_decision exactly once with read_only or implementation and a concise reason.",
187
+ "Call task_mutation_decision exactly once with read_only or implementation, a confidence level, and a concise reason.",
169
188
  ].join("\n"),
170
189
  model: runtime.model,
171
190
  tools: [tool],
172
191
  },
173
192
  convertToLlm,
174
- streamFunction: authWrappedStreamFn(runtime.baseStreamFn, auth),
193
+ ...agentStreamOptions(authWrappedStreamFn(runtime.baseStreamFn, auth)),
175
194
  getApiKey: (providerName) =>
176
195
  providerName === runtime.model.provider ? auth.apiKey : undefined,
177
196
  beforeToolCall: async ({ toolCall }) =>
@@ -179,13 +198,12 @@ async function runArbitration(
179
198
  toolExecution: "sequential",
180
199
  });
181
200
  try {
182
- // Send head AND tail: an implementation clause at the end of a long
183
- // task ("Now apply the fix") must never be truncated away, or the
184
- // arbiter could rescue a run the deterministic guard correctly failed.
185
- const full = task;
186
- const prompt = full.length <= 8000
187
- ? `TASK:\n${full}`
188
- : `TASK:\n${full.slice(0, 6000)}\n\n[...truncated middle...]\n\n${full.slice(-2000)}`;
201
+ // The rescue gate refuses tasks over 8000 chars; if the arbiter is
202
+ // still invoked with one, fail closed rather than decide from
203
+ // partial evidence (an implementation clause could sit in the
204
+ // omitted middle).
205
+ if (task.length > 8000) return "unavailable";
206
+ const prompt = `TASK:\n${task}`;
189
207
  await Promise.race([
190
208
  agent.prompt(prompt),
191
209
  new Promise<never>((_, reject) => {
@@ -197,7 +215,7 @@ async function runArbitration(
197
215
  }),
198
216
  ]);
199
217
  if (!decision) return "unavailable";
200
- return decision.classification === "read_only" ? "read-only" : "implementation";
218
+ return mapArbiterDecision(decision);
201
219
  } catch {
202
220
  return "unavailable";
203
221
  }
@@ -228,13 +246,6 @@ export function isCompletionGuardFailure(result: { error?: string }): boolean {
228
246
  return result.error?.startsWith(COMPLETION_GUARD_ERROR_PREFIX) === true;
229
247
  }
230
248
 
231
- /**
232
- * Decision helper for the runSync completion-guard arbitration: given the raw
233
- * guard verdict, decide whether the arbiter rescues the run. Pure and
234
- * unit-testable; runSync calls this BEFORE any failure side effect is
235
- * published. Only a confident read-only verdict rescues; every error,
236
- * timeout, or other verdict keeps the guard's original behavior.
237
- */
238
249
  export async function arbitrateCompletionGuardRescue(input: {
239
250
  guardTriggered: boolean;
240
251
  task: string;
@@ -243,6 +254,11 @@ export async function arbitrateCompletionGuardRescue(input: {
243
254
  if (!input.guardTriggered || !input.arbiter) {
244
255
  return { triggered: input.guardTriggered, rescued: false };
245
256
  }
257
+ // Never decide from partial evidence: refuse tasks over 8000 chars before
258
+ // even consulting the model.
259
+ if (input.task.length > 8000) {
260
+ return { triggered: true, rescued: false };
261
+ }
246
262
  try {
247
263
  const verdict = await input.arbiter(input.task);
248
264
  if (verdict === "read-only") return { triggered: false, rescued: true };
@@ -0,0 +1,5 @@
1
+ import type { StreamFn } from "@earendil-works/pi-agent-core";
2
+
3
+ export function agentStreamOptions(streamFn: StreamFn): { streamFunction: StreamFn; streamFn: StreamFn } {
4
+ return { streamFunction: streamFn, streamFn };
5
+ }
@@ -65,10 +65,6 @@ function patternMatchesArtifactPath(pattern: string, artifactPath: string): bool
65
65
  || globMatchesPath(normalized, artifactPath);
66
66
  }
67
67
 
68
- function patternMatchesProjectArtifacts(pattern: string): boolean {
69
- return PROJECT_ARTIFACT_PATHS.some((artifactPath) => patternMatchesArtifactPath(pattern, artifactPath));
70
- }
71
-
72
68
  function ignoreFileExcludesProjectArtifacts(filePath: string): boolean {
73
69
  if (!fs.existsSync(filePath)) return false;
74
70
  try {
@@ -144,7 +140,7 @@ export function getProjectChainRunsDir(cwd: string): string {
144
140
 
145
141
  export function getChainRunsDir(
146
142
  projectCwd: string,
147
- dirPreference: ArtifactDirPreference = "project",
143
+ dirPreference: ArtifactDirPreference = "session",
148
144
  ): string {
149
145
  switch (dirPreference) {
150
146
  case "project":
@@ -160,7 +156,7 @@ export function getChainRunsDir(
160
156
  export function getArtifactsDir(
161
157
  sessionFile: string | null,
162
158
  projectCwd?: string,
163
- dirPreference: ArtifactDirPreference = "project",
159
+ dirPreference: ArtifactDirPreference = "session",
164
160
  ): string {
165
161
  switch (dirPreference) {
166
162
  case "session":
@@ -98,3 +98,53 @@ export function previewDisplayText(value: string, maxLength: number): string {
98
98
  if (maxLength <= 3) return truncateDisplayText(normalized, maxLength);
99
99
  return `${truncateDisplayText(normalized, maxLength - 3)}...`;
100
100
  }
101
+
102
+ const BINARY_CONTENT_PLACEHOLDER = "[binary content omitted for safe display]";
103
+
104
+ function isUnsafeTerminalCodePoint(codePoint: number): boolean {
105
+ const terminalControl = (codePoint <= 0x1f && codePoint !== 0x09 && codePoint !== 0x0a)
106
+ || (codePoint >= 0x7f && codePoint <= 0x9f);
107
+ const bidiControl = codePoint === 0x061c
108
+ || codePoint === 0x200e
109
+ || codePoint === 0x200f
110
+ || (codePoint >= 0x202a && codePoint <= 0x202e)
111
+ || (codePoint >= 0x2066 && codePoint <= 0x2069);
112
+ const privateUse = (codePoint >= 0xe000 && codePoint <= 0xf8ff)
113
+ || (codePoint >= 0xf0000 && codePoint <= 0xffffd)
114
+ || (codePoint >= 0x100000 && codePoint <= 0x10fffd);
115
+ const invalidScalar = codePoint >= 0xd800 && codePoint <= 0xdfff;
116
+ const nonCharacter = (codePoint >= 0xfdd0 && codePoint <= 0xfdef)
117
+ || (codePoint & 0xffff) === 0xfffe
118
+ || (codePoint & 0xffff) === 0xffff;
119
+ return terminalControl || bidiControl || privateUse || invalidScalar || nonCharacter;
120
+ }
121
+
122
+ function looksLikeBinaryContent(text: string): boolean {
123
+ if (text.includes("\0")) return true;
124
+ let suspiciousControls = 0;
125
+ let replacementCharacters = 0;
126
+ let codePoints = 0;
127
+ for (const character of text) {
128
+ codePoints++;
129
+ const codePoint = character.codePointAt(0) ?? 0;
130
+ if ((codePoint <= 0x08) || (codePoint >= 0x0e && codePoint <= 0x1f)) suspiciousControls++;
131
+ if (codePoint === 0xfffd) replacementCharacters++;
132
+ }
133
+ if (codePoints === 0) return false;
134
+ return (suspiciousControls >= 4 && suspiciousControls / codePoints >= 0.1)
135
+ || (replacementCharacters >= 3 && replacementCharacters / codePoints >= 0.1);
136
+ }
137
+
138
+ /** Escapes untrusted text before rendering it in a terminal while preserving safe newlines and tabs. */
139
+ export function safeTerminalText(value: string): string {
140
+ const normalized = value.replace(/\r\n/g, "\n");
141
+ if (looksLikeBinaryContent(normalized)) return BINARY_CONTENT_PLACEHOLDER;
142
+ let safe = "";
143
+ for (const character of normalized) {
144
+ const codePoint = character.codePointAt(0) ?? 0;
145
+ safe += isUnsafeTerminalCodePoint(codePoint)
146
+ ? `[U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}]`
147
+ : character;
148
+ }
149
+ return safe;
150
+ }
@@ -0,0 +1,21 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ function isNodeExecutableName(execPath: string): boolean {
5
+ const basename = path.basename(execPath).toLowerCase();
6
+ return basename === "node" || basename === "node.exe" || basename === "nodejs" || basename === "nodejs.exe";
7
+ }
8
+
9
+ function canUseNodeExecutable(execPath: string): boolean {
10
+ try {
11
+ fs.accessSync(execPath, process.platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function resolveNodeExecutable(execPath = process.execPath): string {
19
+ if (isNodeExecutableName(execPath) && canUseNodeExecutable(execPath)) return execPath;
20
+ return process.platform === "win32" ? "node.exe" : "node";
21
+ }
@@ -528,6 +528,8 @@ export interface SteeringRecoveryDescriptor {
528
528
  structuredOutputSchema?: JsonSchemaObject;
529
529
  acceptance?: AcceptanceInput;
530
530
  controlConfig?: ResolvedControlConfig;
531
+ /** Raw per-run bridge override. Omitted descriptors continue to use global config. */
532
+ intercomBridge?: IntercomBridgeConfig;
531
533
  absoluteDeadlineAt?: number;
532
534
  initialTurnBudget?: ResolvedTurnBudget;
533
535
  initialToolBudget?: ResolvedToolBudget;
@@ -1086,7 +1088,7 @@ export interface Details {
1086
1088
  trace: Array<{
1087
1089
  operation: "run" | "status";
1088
1090
  key: string;
1089
- state: "started" | "completed" | "failed" | "reused";
1091
+ state: "started" | "completed" | "failed" | "stopped" | "reused";
1090
1092
  agent?: string;
1091
1093
  runId?: string;
1092
1094
  phase?: string;
@@ -1259,6 +1261,8 @@ export interface AsyncStartedEvent {
1259
1261
  asyncDir?: string;
1260
1262
  /** Parent-resolved launch directory, used as a trusted artifact root while this session is live. */
1261
1263
  cwd?: string;
1264
+ /** Parent-resolved child session root, used only while this session owns the live run. */
1265
+ sessionRoot?: string;
1262
1266
  pid?: number;
1263
1267
  sessionId?: string;
1264
1268
  mode?: SubagentRunMode;
@@ -1462,6 +1466,8 @@ export interface AsyncJobState {
1462
1466
  asyncDir: string;
1463
1467
  /** Parent-resolved launch directory retained for trusted live artifact lookup. */
1464
1468
  cwd?: string;
1469
+ /** Parent-resolved child session root retained for trusted live transcript lookup. */
1470
+ sessionRoot?: string;
1465
1471
  status: "queued" | "running" | "complete" | "failed" | "paused" | "stopped" | "rejected";
1466
1472
  /** Short caller-facing task/goal shown in fleet surfaces when available. */
1467
1473
  description?: string;
@@ -1661,6 +1667,10 @@ export interface SubagentState {
1661
1667
  /** Runtime mission-store snapshot used by optional inspector context. */
1662
1668
  missionStoreConfig?: MissionStoreConfig;
1663
1669
  parentSessionFile?: string | null;
1670
+ /** Extension-owned roots trusted for child session transcript reads. */
1671
+ trustedSessionRoots?: string[];
1672
+ /** Live async session roots created by this parent executor, keyed by run id. */
1673
+ liveAsyncSessionRoots?: Map<string, string>;
1664
1674
  /** Last valid parent session model observed for this session; used when continuation contexts omit ctx.model. */
1665
1675
  lastParentModel?: { provider: string; id: string };
1666
1676
  subagentInProgress?: boolean;
@@ -1882,6 +1892,13 @@ export const FLEET_KEYBINDING_ACTIONS = [
1882
1892
  export type FleetKeybindingAction = typeof FLEET_KEYBINDING_ACTIONS[number];
1883
1893
  export type FleetKeybindingsConfig = Partial<Record<FleetKeybindingAction, string[]>>;
1884
1894
 
1895
+ export interface MainWindowRendererConfig {
1896
+ /** Unit of horizontal space in main chat subagent call/result rows. Omit to preserve current spacing. Set 0 for no extra padding. */
1897
+ horizontalSpacing?: number;
1898
+ /** Maximum collapsed rich-result rows. Expanded output is not capped. */
1899
+ compactResultMaxLines?: number;
1900
+ }
1901
+
1885
1902
  export interface ExtensionConfig {
1886
1903
  asyncByDefault?: boolean;
1887
1904
  /** Show the Claude Code-style navigable fleet. Defaults to true. */
@@ -1898,6 +1915,8 @@ export interface ExtensionConfig {
1898
1915
  legacyChainControls?: boolean;
1899
1916
  /** Inline chat rendering for the subagent tool. Defaults to rich. */
1900
1917
  inlineToolDisplay?: InlineToolDisplay;
1918
+ /** Density controls for the main chat subagent call/result renderer. */
1919
+ mainWindowRenderer?: MainWindowRendererConfig;
1901
1920
  forceTopLevelAsync?: boolean;
1902
1921
  waitTool?: WaitToolConfig;
1903
1922
  defaultSessionDir?: string;
@@ -1933,7 +1952,7 @@ export interface ExtensionConfig {
1933
1952
  worktreeSetupHook?: string;
1934
1953
  worktreeSetupHookTimeoutMs?: number;
1935
1954
  worktreeBaseDir?: string;
1936
- /** Where to store subagent artifact files. Defaults to "project" (cwd/.pi/subagents). Set to "session" for pi session dir, or "temp" for OS temp. */
1955
+ /** Where to store subagent artifact files. Defaults to "session" (the pi session directory, or OS temp when unavailable). Set to "project" for cwd/.pi/subagents. */
1937
1956
  artifactDir?: ArtifactDirPreference;
1938
1957
  /** Artifact cleanup retention. Set cleanupDays to 0 to disable cleanup. */
1939
1958
  artifactConfig?: Pick<ArtifactConfig, "cleanupDays">;
@@ -1957,7 +1976,7 @@ export const DEFAULT_MAX_OUTPUT: Required<MaxOutputConfig> = {
1957
1976
 
1958
1977
  export const DEFAULT_ARTIFACT_CONFIG: ArtifactConfig = {
1959
1978
  enabled: true,
1960
- dir: "project",
1979
+ dir: "session",
1961
1980
  includeInput: true,
1962
1981
  includeOutput: true,
1963
1982
  includeJsonl: false,
@@ -2044,7 +2063,7 @@ export const SLASH_SUBAGENT_CANCEL_EVENT = "subagent:slash:cancel";
2044
2063
  export const POLL_INTERVAL_MS = 250;
2045
2064
  export const MAX_WIDGET_JOBS = 4;
2046
2065
  export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;
2047
- export const SUBAGENT_ACTIONS = ["list", "get", "models", "children.list", "guide", "create", "update", "delete", "eject", "disable", "enable", "reset", "mission.create", "mission.list", "mission.show", "mission.update", "mission.resolve-decision", "mission.attach-run", "mission.close", "worktree.discard", "refine", "refine.show", "refine.rollback", "inspector.open", "inspector.status", "inspector.close", "project.open", "project.status", "project.close", "status", "grant-spawn-budget", "interrupt", "resume", "steer", "stop", "dismiss", "append-step", "approve-checkpoint", "reject-checkpoint", "doctor", "watchdog.status", "watchdog.check", "watchdog.configure", "watchdog.recommend-model", "schedule.create", "schedule.list", "schedule.show", "schedule.history", "schedule.pause", "schedule.resume", "schedule.run", "schedule.run-due", "schedule.delete"] as const;
2066
+ export const SUBAGENT_ACTIONS = ["list", "get", "models", "children.list", "guide", "create", "update", "delete", "eject", "disable", "enable", "reset", "mission.create", "mission.list", "mission.show", "mission.update", "mission.resolve-decision", "mission.attach-run", "mission.close", "worktree.discard", "refine", "refine.show", "refine.rollback", "inspector.open", "inspector.status", "inspector.close", "project.open", "project.status", "project.close", "status", "debug.run", "grant-spawn-budget", "interrupt", "resume", "steer", "stop", "dismiss", "append-step", "approve-checkpoint", "reject-checkpoint", "doctor", "watchdog.status", "watchdog.check", "watchdog.configure", "watchdog.recommend-model", "schedule.create", "schedule.list", "schedule.show", "schedule.history", "schedule.pause", "schedule.resume", "schedule.run", "schedule.run-due", "schedule.delete"] as const;
2048
2067
 
2049
2068
  export const DEFAULT_FORK_PREAMBLE =
2050
2069
  "You are a delegated subagent running from a fork of the parent session. " +
@@ -2,59 +2,12 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { getLanguageFromPath, highlightCode, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi, type MarkdownTheme } from "@earendil-works/pi-tui";
5
+ import { safeTerminalText as safeDisplayText } from "../shared/display-text.ts";
5
6
 
6
7
  const DEFAULT_MAX_RECORDS = 240;
7
8
  const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
8
9
  const MAX_MESSAGE_CHARS = 64 * 1024;
9
10
  const TOOL_PREVIEW_LINES = 7;
10
- const BINARY_CONTENT_PLACEHOLDER = "[binary content omitted for safe display]";
11
-
12
- function isUnsafeDisplayCodePoint(codePoint: number): boolean {
13
- const terminalControl = (codePoint <= 0x1f && codePoint !== 0x09 && codePoint !== 0x0a)
14
- || (codePoint >= 0x7f && codePoint <= 0x9f);
15
- const bidiControl = codePoint === 0x061c
16
- || codePoint === 0x200e
17
- || codePoint === 0x200f
18
- || (codePoint >= 0x202a && codePoint <= 0x202e)
19
- || (codePoint >= 0x2066 && codePoint <= 0x2069);
20
- const privateUse = (codePoint >= 0xe000 && codePoint <= 0xf8ff)
21
- || (codePoint >= 0xf0000 && codePoint <= 0xffffd)
22
- || (codePoint >= 0x100000 && codePoint <= 0x10fffd);
23
- const invalidScalar = codePoint >= 0xd800 && codePoint <= 0xdfff;
24
- const nonCharacter = (codePoint >= 0xfdd0 && codePoint <= 0xfdef)
25
- || (codePoint & 0xffff) === 0xfffe
26
- || (codePoint & 0xffff) === 0xffff;
27
- return terminalControl || bidiControl || privateUse || invalidScalar || nonCharacter;
28
- }
29
-
30
- function looksLikeBinaryContent(text: string): boolean {
31
- if (text.includes("\0")) return true;
32
- let suspiciousControls = 0;
33
- let replacementCharacters = 0;
34
- let codePoints = 0;
35
- for (const character of text) {
36
- codePoints++;
37
- const codePoint = character.codePointAt(0) ?? 0;
38
- if ((codePoint <= 0x08) || (codePoint >= 0x0e && codePoint <= 0x1f)) suspiciousControls++;
39
- if (codePoint === 0xfffd) replacementCharacters++;
40
- }
41
- if (codePoints === 0) return false;
42
- return (suspiciousControls >= 4 && suspiciousControls / codePoints >= 0.1)
43
- || (replacementCharacters >= 3 && replacementCharacters / codePoints >= 0.1);
44
- }
45
-
46
- function safeDisplayText(text: string): string {
47
- const normalized = text.replace(/\r\n/g, "\n");
48
- if (looksLikeBinaryContent(normalized)) return BINARY_CONTENT_PLACEHOLDER;
49
- let safe = "";
50
- for (const character of normalized) {
51
- const codePoint = character.codePointAt(0) ?? 0;
52
- safe += isUnsafeDisplayCodePoint(codePoint)
53
- ? `[U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}]`
54
- : character;
55
- }
56
- return safe;
57
- }
58
11
 
59
12
  function sanitizeJsonDisplayValue(value: unknown): { value: unknown; changed: boolean } {
60
13
  if (typeof value === "string") {
package/src/tui/fleet.ts CHANGED
@@ -473,7 +473,7 @@ function fleetArtifactsRoot(state: SubagentState, cwd: string): string {
473
473
  return getArtifactsDir(
474
474
  state.parentSessionFile ?? null,
475
475
  cwd,
476
- state.artifactDirPreference ?? "project",
476
+ state.artifactDirPreference,
477
477
  );
478
478
  }
479
479
 
@@ -510,6 +510,7 @@ function transcriptTarget(item: FleetItem, state: SubagentState): { path: string
510
510
  item.run.asyncDir,
511
511
  fleetArtifactsRoot(state, state.baseCwd),
512
512
  trackedJob?.cwd ? fleetArtifactsRoot(state, trackedJob.cwd) : undefined,
513
+ item.run.sessionFile ? getArtifactsDir(item.run.sessionFile, item.run.cwd ?? state.baseCwd, state.artifactDirPreference) : undefined,
513
514
  ]),
514
515
  };
515
516
  }
@@ -1178,6 +1179,7 @@ export async function openSubagentFleet(ctx: ExtensionContext, state: SubagentSt
1178
1179
  ...(input.index !== undefined ? { index: input.index } : {}),
1179
1180
  }, {
1180
1181
  state,
1182
+ sessionRoots: state.trustedSessionRoots,
1181
1183
  cwd: state.baseCwd,
1182
1184
  ...(state.authorityPolicy ? { authorityPolicy: state.authorityPolicy } : {}),
1183
1185
  ...(state.missionStoreConfig ? { missions: state.missionStoreConfig } : {}),