instar 1.3.343 → 1.3.345

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 (147) hide show
  1. package/dist/commands/init.d.ts +4 -4
  2. package/dist/commands/init.d.ts.map +1 -1
  3. package/dist/commands/init.js.map +1 -1
  4. package/dist/commands/reflect.d.ts.map +1 -1
  5. package/dist/commands/reflect.js +7 -0
  6. package/dist/commands/reflect.js.map +1 -1
  7. package/dist/commands/server.d.ts.map +1 -1
  8. package/dist/commands/server.js +126 -8
  9. package/dist/commands/server.js.map +1 -1
  10. package/dist/commands/setup.d.ts +1 -1
  11. package/dist/commands/setup.d.ts.map +1 -1
  12. package/dist/commands/setup.js.map +1 -1
  13. package/dist/core/Config.d.ts +12 -3
  14. package/dist/core/Config.d.ts.map +1 -1
  15. package/dist/core/Config.js +40 -6
  16. package/dist/core/Config.js.map +1 -1
  17. package/dist/core/FrameworkSessionStore.d.ts +1 -1
  18. package/dist/core/FrameworkSessionStore.d.ts.map +1 -1
  19. package/dist/core/PiCliIntelligenceProvider.d.ts +52 -0
  20. package/dist/core/PiCliIntelligenceProvider.d.ts.map +1 -0
  21. package/dist/core/PiCliIntelligenceProvider.js +71 -0
  22. package/dist/core/PiCliIntelligenceProvider.js.map +1 -0
  23. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  24. package/dist/core/PostUpdateMigrator.js +57 -0
  25. package/dist/core/PostUpdateMigrator.js.map +1 -1
  26. package/dist/core/PreCompactionFlush.d.ts +1 -1
  27. package/dist/core/PreCompactionFlush.d.ts.map +1 -1
  28. package/dist/core/ResumeValidator.d.ts +1 -1
  29. package/dist/core/ResumeValidator.d.ts.map +1 -1
  30. package/dist/core/SessionManager.d.ts +130 -0
  31. package/dist/core/SessionManager.d.ts.map +1 -1
  32. package/dist/core/SessionManager.js +545 -10
  33. package/dist/core/SessionManager.js.map +1 -1
  34. package/dist/core/TopicFrameworksStore.d.ts.map +1 -1
  35. package/dist/core/TopicFrameworksStore.js +3 -0
  36. package/dist/core/TopicFrameworksStore.js.map +1 -1
  37. package/dist/core/frameworkInjectionProcesses.d.ts.map +1 -1
  38. package/dist/core/frameworkInjectionProcesses.js +4 -0
  39. package/dist/core/frameworkInjectionProcesses.js.map +1 -1
  40. package/dist/core/frameworkSessionLaunch.d.ts +8 -0
  41. package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
  42. package/dist/core/frameworkSessionLaunch.js +73 -0
  43. package/dist/core/frameworkSessionLaunch.js.map +1 -1
  44. package/dist/core/intelligenceProviderFactory.d.ts +15 -1
  45. package/dist/core/intelligenceProviderFactory.d.ts.map +1 -1
  46. package/dist/core/intelligenceProviderFactory.js +39 -1
  47. package/dist/core/intelligenceProviderFactory.js.map +1 -1
  48. package/dist/core/types.d.ts +88 -8
  49. package/dist/core/types.d.ts.map +1 -1
  50. package/dist/core/types.js.map +1 -1
  51. package/dist/messaging/SpawnRequestManager.d.ts +14 -0
  52. package/dist/messaging/SpawnRequestManager.d.ts.map +1 -1
  53. package/dist/messaging/SpawnRequestManager.js +17 -0
  54. package/dist/messaging/SpawnRequestManager.js.map +1 -1
  55. package/dist/messaging/shared/telegramRelayPrompt.d.ts +1 -1
  56. package/dist/messaging/shared/telegramRelayPrompt.d.ts.map +1 -1
  57. package/dist/monitoring/ApprenticeshipCycleStore.d.ts +59 -1
  58. package/dist/monitoring/ApprenticeshipCycleStore.d.ts.map +1 -1
  59. package/dist/monitoring/ApprenticeshipCycleStore.js +77 -2
  60. package/dist/monitoring/ApprenticeshipCycleStore.js.map +1 -1
  61. package/dist/monitoring/ReapLog.d.ts +6 -0
  62. package/dist/monitoring/ReapLog.d.ts.map +1 -1
  63. package/dist/monitoring/ReapLog.js +1 -0
  64. package/dist/monitoring/ReapLog.js.map +1 -1
  65. package/dist/monitoring/SessionReaper.d.ts +1 -1
  66. package/dist/monitoring/SessionReaper.d.ts.map +1 -1
  67. package/dist/monitoring/SessionReaper.js.map +1 -1
  68. package/dist/monitoring/frameworkActivitySignals.d.ts.map +1 -1
  69. package/dist/monitoring/frameworkActivitySignals.js +21 -0
  70. package/dist/monitoring/frameworkActivitySignals.js.map +1 -1
  71. package/dist/monitoring/frameworkProcessSignals.d.ts.map +1 -1
  72. package/dist/monitoring/frameworkProcessSignals.js +22 -0
  73. package/dist/monitoring/frameworkProcessSignals.js.map +1 -1
  74. package/dist/providers/adapters/pi-cli/capabilities.d.ts +31 -0
  75. package/dist/providers/adapters/pi-cli/capabilities.d.ts.map +1 -0
  76. package/dist/providers/adapters/pi-cli/capabilities.js +40 -0
  77. package/dist/providers/adapters/pi-cli/capabilities.js.map +1 -0
  78. package/dist/providers/adapters/pi-cli/config.d.ts +65 -0
  79. package/dist/providers/adapters/pi-cli/config.d.ts.map +1 -0
  80. package/dist/providers/adapters/pi-cli/config.js +43 -0
  81. package/dist/providers/adapters/pi-cli/config.js.map +1 -0
  82. package/dist/providers/adapters/pi-cli/control/piHardKill.d.ts +19 -0
  83. package/dist/providers/adapters/pi-cli/control/piHardKill.d.ts.map +1 -0
  84. package/dist/providers/adapters/pi-cli/control/piHardKill.js +67 -0
  85. package/dist/providers/adapters/pi-cli/control/piHardKill.js.map +1 -0
  86. package/dist/providers/adapters/pi-cli/errors.d.ts +43 -0
  87. package/dist/providers/adapters/pi-cli/errors.d.ts.map +1 -0
  88. package/dist/providers/adapters/pi-cli/errors.js +71 -0
  89. package/dist/providers/adapters/pi-cli/errors.js.map +1 -0
  90. package/dist/providers/adapters/pi-cli/index.d.ts +39 -0
  91. package/dist/providers/adapters/pi-cli/index.d.ts.map +1 -0
  92. package/dist/providers/adapters/pi-cli/index.js +69 -0
  93. package/dist/providers/adapters/pi-cli/index.js.map +1 -0
  94. package/dist/providers/adapters/pi-cli/observability/sessionId.d.ts +27 -0
  95. package/dist/providers/adapters/pi-cli/observability/sessionId.d.ts.map +1 -0
  96. package/dist/providers/adapters/pi-cli/observability/sessionId.js +53 -0
  97. package/dist/providers/adapters/pi-cli/observability/sessionId.js.map +1 -0
  98. package/dist/providers/adapters/pi-cli/policy.d.ts +44 -0
  99. package/dist/providers/adapters/pi-cli/policy.d.ts.map +1 -0
  100. package/dist/providers/adapters/pi-cli/policy.js +74 -0
  101. package/dist/providers/adapters/pi-cli/policy.js.map +1 -0
  102. package/dist/providers/adapters/pi-cli/transport/agenticSessionRpc.d.ts +34 -0
  103. package/dist/providers/adapters/pi-cli/transport/agenticSessionRpc.d.ts.map +1 -0
  104. package/dist/providers/adapters/pi-cli/transport/agenticSessionRpc.js +194 -0
  105. package/dist/providers/adapters/pi-cli/transport/agenticSessionRpc.js.map +1 -0
  106. package/dist/providers/adapters/pi-cli/transport/oneShotCompletion.d.ts +33 -0
  107. package/dist/providers/adapters/pi-cli/transport/oneShotCompletion.d.ts.map +1 -0
  108. package/dist/providers/adapters/pi-cli/transport/oneShotCompletion.js +166 -0
  109. package/dist/providers/adapters/pi-cli/transport/oneShotCompletion.js.map +1 -0
  110. package/dist/providers/adapters/pi-cli/transport/piSpawn.d.ts +87 -0
  111. package/dist/providers/adapters/pi-cli/transport/piSpawn.d.ts.map +1 -0
  112. package/dist/providers/adapters/pi-cli/transport/piSpawn.js +242 -0
  113. package/dist/providers/adapters/pi-cli/transport/piSpawn.js.map +1 -0
  114. package/dist/providers/adapters/pi-cli/transport/rpcClient.d.ts +103 -0
  115. package/dist/providers/adapters/pi-cli/transport/rpcClient.d.ts.map +1 -0
  116. package/dist/providers/adapters/pi-cli/transport/rpcClient.js +238 -0
  117. package/dist/providers/adapters/pi-cli/transport/rpcClient.js.map +1 -0
  118. package/dist/providers/bootRegistration.d.ts +38 -0
  119. package/dist/providers/bootRegistration.d.ts.map +1 -1
  120. package/dist/providers/bootRegistration.js +52 -0
  121. package/dist/providers/bootRegistration.js.map +1 -1
  122. package/dist/providers/parity/rules/skillParityRule.d.ts.map +1 -1
  123. package/dist/providers/parity/rules/skillParityRule.js +6 -0
  124. package/dist/providers/parity/rules/skillParityRule.js.map +1 -1
  125. package/dist/scaffold/templates.d.ts.map +1 -1
  126. package/dist/scaffold/templates.js +3 -2
  127. package/dist/scaffold/templates.js.map +1 -1
  128. package/dist/scheduler/JobScheduler.d.ts.map +1 -1
  129. package/dist/scheduler/JobScheduler.js +22 -0
  130. package/dist/scheduler/JobScheduler.js.map +1 -1
  131. package/dist/server/routes.d.ts.map +1 -1
  132. package/dist/server/routes.js +10 -3
  133. package/dist/server/routes.js.map +1 -1
  134. package/dist/threadline/PipeSessionSpawner.d.ts +23 -0
  135. package/dist/threadline/PipeSessionSpawner.d.ts.map +1 -1
  136. package/dist/threadline/PipeSessionSpawner.js +27 -0
  137. package/dist/threadline/PipeSessionSpawner.js.map +1 -1
  138. package/package.json +2 -2
  139. package/scripts/lint-no-direct-destructive.js +3 -0
  140. package/scripts/lint-no-unfunneled-headless-launch.js +128 -0
  141. package/src/data/builtin-manifest.json +65 -65
  142. package/src/scaffold/templates.ts +3 -2
  143. package/upgrades/1.3.344.md +46 -0
  144. package/upgrades/1.3.345.md +154 -0
  145. package/upgrades/side-effects/apprenticeship-layer-balance.md +50 -0
  146. package/upgrades/side-effects/june15-headless-spawn-reroute.md +105 -0
  147. package/upgrades/side-effects/pi-harness-integration.md +82 -0
@@ -22,6 +22,7 @@ import { detectRateLimited } from '../monitoring/rateLimitDetection.js';
22
22
  const execFileAsync = promisify(execFile);
23
23
  import { buildInteractiveLaunch, buildHeadlessLaunch, claudeHeadlessExtraFlags, resolveInteractiveFramework, resolveModelForFramework, } from './frameworkSessionLaunch.js';
24
24
  import { frameworkFromEnv } from './intelligenceProviderFactory.js';
25
+ import { decideSdkVsSubscription, DEFAULT_SAFETY_MARGIN_FRACTION } from '../providers/costAwareRouting.js';
25
26
  import { resolveCodexLaunchModelWithUsage, } from '../providers/adapters/openai-codex/observability/codexModelSwapPolicy.js';
26
27
  import { buildInjectionTag } from '../types/pipeline.js';
27
28
  import { sanitizeSenderName, sanitizeTopicName } from '../utils/sanitize.js';
@@ -240,6 +241,19 @@ export class SessionManager extends EventEmitter {
240
241
  pendingInjects;
241
242
  /** Per-session shim directory root (one subdir per session). Used for K9 mandatory shim. */
242
243
  shimRoot = null;
244
+ /**
245
+ * SDK-credit reader for the june15-headless-spawn-reroute 'auto' decision
246
+ * (PR2). Plumbed from server boot's anthropicRegistration.readSdkCredit (the
247
+ * SAME TTL-cached reader PR1's routing policy uses, so the two routing layers
248
+ * cannot drift). Returns null when credit state is unknown → the shared
249
+ * decideSdkVsSubscription falls to the subscription floor. Unset (tests/
250
+ * standalone) ⇒ 'auto' is treated as always-reroute-on-unknown (== subscription
251
+ * floor), matching the pure function's null-snapshot contract. */
252
+ readSdkCredit;
253
+ /** Last reroute-decision per framework, so the transition-only log line fires
254
+ * on a CHANGE rather than every spawn (mirrors PR1's onRoute). Keyed by
255
+ * framework; value is the resolved path string. */
256
+ lastRerouteDecision = new Map();
243
257
  constructor(config, state) {
244
258
  super();
245
259
  this.config = config;
@@ -401,6 +415,17 @@ rm() { "${shimRunner}" rm "$@"; }
401
415
  setReapGuard(guard) {
402
416
  this.reapGuard = guard;
403
417
  }
418
+ /**
419
+ * Wire the SDK-credit reader used by the headless-spawn-reroute 'auto'
420
+ * decision (june15-headless-spawn-reroute, PR2). Server boot passes
421
+ * anthropicRegistration.readSdkCredit — the same TTL-cached reader PR1's
422
+ * routing policy uses — so the reroute decision and the intelligence-funnel
423
+ * routing share ONE credit source and can't drift. Unset ⇒ 'auto' resolves to
424
+ * the subscription floor (null-snapshot contract of decideSdkVsSubscription).
425
+ */
426
+ setSdkCreditReader(reader) {
427
+ this.readSdkCredit = reader;
428
+ }
404
429
  /**
405
430
  * Flag/unflag a session as long-`indeterminate` (UNIFIED-SESSION-LIFECYCLE §P5).
406
431
  * Driven by the StaleSessionBackstop. A flagged session still counts toward the
@@ -743,6 +768,59 @@ rm() { "${shimRunner}" rm "$@"; }
743
768
  continue;
744
769
  }
745
770
  this.recordBuildContext(session.tmuxSession);
771
+ // ── Rerouted-interactive completion branch (june15-headless-spawn-
772
+ // reroute, PR2 O1) ──
773
+ // A rerouted session (completionMode==='pattern') never exits on task
774
+ // success, so completion is detected by (a) its OWN sentinel pattern and
775
+ // (b) a hard lifetime cap as belt-and-suspenders. This branch handles
776
+ // those sessions and `continue`s; the default lane (completionMode unset
777
+ // or 'exit') falls through to today's global detectCompletion below,
778
+ // byte-for-byte unchanged.
779
+ if (session.completionMode === 'pattern') {
780
+ // (a) Sentinel match → terminate via the SUCCESS path. We use
781
+ // finalStatus:'completed' so sessionComplete carries status
782
+ // 'completed' → JobScheduler records SUCCESS (JobScheduler.ts:1109
783
+ // maps only 'killed' → 'timeout'). A sentinel completion is a clean
784
+ // finish, NOT a timeout.
785
+ if (!this.config.protectedSessions.includes(session.tmuxSession) &&
786
+ this.detectSessionCompletion(session)) {
787
+ console.log(`[SessionManager] Rerouted session "${session.name}" completed (sentinel detected). Reaping as success.`);
788
+ await this.terminateSession(session.id, 'sentinel-complete', {
789
+ finalStatus: 'completed',
790
+ disposition: 'terminal',
791
+ });
792
+ continue;
793
+ }
794
+ // (b) Hard lifetime cap → kill + report. This IS a timeout (the model
795
+ // never printed the sentinel), so finalStatus:'killed' → JobScheduler
796
+ // records 'timeout'. The cap bounds the leak when the model phrases
797
+ // its finish differently than the sentinel.
798
+ if (session.startedAt && session.maxLifetimeMinutes) {
799
+ const lifeElapsedMin = (Date.now() - new Date(session.startedAt).getTime()) / 60000;
800
+ if (lifeElapsedMin > session.maxLifetimeMinutes &&
801
+ !this.config.protectedSessions.includes(session.tmuxSession)) {
802
+ console.warn(`[SessionManager] Rerouted session "${session.name}" exceeded its hard lifetime (${Math.round(lifeElapsedMin)}m > ${session.maxLifetimeMinutes}m) without a completion sentinel. Killing (timeout).`);
803
+ const killed = await this.terminateSession(session.id, 'rerouted-lifetime', {
804
+ finalStatus: 'killed',
805
+ disposition: 'terminal',
806
+ });
807
+ if (killed.terminated) {
808
+ DegradationReporter.getInstance().report({
809
+ feature: 'SessionManager.monitorTick.reroutedLifetime',
810
+ primary: 'rerouted-interactive session completes via its sentinel within the lifetime cap',
811
+ fallback: 'killed on the hard lifetime cap (recorded as timeout)',
812
+ reason: `Session "${session.name}" ran ${Math.round(lifeElapsedMin)}m without printing its completion sentinel`,
813
+ impact: 'Rerouted job recorded a timeout; it reruns cleanly on its next trigger',
814
+ });
815
+ }
816
+ continue;
817
+ }
818
+ }
819
+ // Still running, no sentinel, under lifetime — leave it; re-check next
820
+ // tick. Skip the rest of the loop body (the default exit/idle paths
821
+ // below are tuned for the headless lane, not a held REPL).
822
+ continue;
823
+ }
746
824
  // Gemini CLI can complete a Telegram turn by rendering the final
747
825
  // assistant block in the TUI without executing the relay script. When a
748
826
  // topic-bound injection is still pending, surface that completed block
@@ -1031,6 +1109,89 @@ rm() { "${shimRunner}" rm "$@"; }
1031
1109
  this.monitorInterval = null;
1032
1110
  }
1033
1111
  }
1112
+ /**
1113
+ * Current host memory-pressure tier (june15-headless-spawn-reroute PR2, O2).
1114
+ * Extracted from getSessionDiagnostics' inline computation so the reroute
1115
+ * pre-spawn gate and the diagnostics surface read ONE definition of pressure.
1116
+ * The reroute gate refuses to hold a fresh ~200-500MB REPL when the host is
1117
+ * already 'high'/'critical' — the 2026-06-05 laptop-meltdown class.
1118
+ */
1119
+ currentMemoryPressure() {
1120
+ const totalMem = os.totalmem();
1121
+ const freeMem = os.freemem();
1122
+ const usedPercent = Math.round(((totalMem - freeMem) / totalMem) * 100);
1123
+ if (usedPercent >= 90)
1124
+ return 'critical';
1125
+ if (usedPercent >= 75)
1126
+ return 'high';
1127
+ if (usedPercent >= 60)
1128
+ return 'moderate';
1129
+ return 'low';
1130
+ }
1131
+ /**
1132
+ * Count live sessions currently on the rerouted-interactive lane
1133
+ * (june15-headless-spawn-reroute PR2, findings S1/O2). The concurrency cap
1134
+ * (subscriptionPath.maxRerouted) bounds the held-REPL memory envelope ACROSS
1135
+ * all spawn classes — jobs, A2A, and mentor spawns alike — since A2A/mentor
1136
+ * spawns carry no jobSlug and would otherwise be bounded only by the global
1137
+ * maxSessions.
1138
+ */
1139
+ countReroutedInteractiveSessions() {
1140
+ return this.listRunningSessions().filter((s) => s.launchLane === 'rerouted-interactive').length;
1141
+ }
1142
+ /**
1143
+ * Decide whether a claude-code headless spawn should be rerouted onto the
1144
+ * interactive (subscription) lane (june15-headless-spawn-reroute PR2, "The
1145
+ * core switch"). ONLY claude-code is eligible — the lever is Anthropic-billing-
1146
+ * specific; codex/gemini headless paths don't draw the Anthropic SDK pot.
1147
+ *
1148
+ * - mode 'off'/absent → false (today's behavior, byte-for-byte)
1149
+ * - mode 'force' → true (zero `claude -p` traffic, the soak lever)
1150
+ * - mode 'auto' → consult the SAME shared decideSdkVsSubscription
1151
+ * + TTL-cached credit reader PR1 uses, so the two
1152
+ * routing layers can't drift. Subscription → reroute.
1153
+ *
1154
+ * Emits a transition-only log line when the resolved lane CHANGES for this
1155
+ * framework (mirrors PR1's onRoute — a line per change, not per spawn).
1156
+ */
1157
+ async shouldRerouteToInteractive(framework) {
1158
+ if (framework !== 'claude-code')
1159
+ return false;
1160
+ const mode = this.config.subscriptionPathMode ?? 'off';
1161
+ if (mode === 'off')
1162
+ return false;
1163
+ let reroute;
1164
+ let reason;
1165
+ if (mode === 'force') {
1166
+ reroute = true;
1167
+ reason = 'mode=force';
1168
+ }
1169
+ else {
1170
+ // 'auto' — null snapshot (unknown / no reader) ⇒ subscription floor.
1171
+ let snapshot = null;
1172
+ if (this.readSdkCredit) {
1173
+ try {
1174
+ snapshot = await this.readSdkCredit();
1175
+ }
1176
+ catch {
1177
+ // @silent-fallback-ok — a credit-read error is the unknown state; the
1178
+ // shared decision below maps null → subscription floor (conservative),
1179
+ // exactly as PR1's routing policy does. No degradation: this is the
1180
+ // designed fallback, not an unexpected failure.
1181
+ snapshot = null;
1182
+ }
1183
+ }
1184
+ const decision = decideSdkVsSubscription(snapshot, DEFAULT_SAFETY_MARGIN_FRACTION);
1185
+ reroute = decision.path === 'subscription';
1186
+ reason = `mode=auto ${decision.reason}`;
1187
+ }
1188
+ const lane = reroute ? 'rerouted-interactive' : 'headless';
1189
+ if (this.lastRerouteDecision.get(framework) !== lane) {
1190
+ this.lastRerouteDecision.set(framework, lane);
1191
+ console.log(`[SessionManager] reroute lane → ${lane} for ${framework} (${reason})`);
1192
+ }
1193
+ return reroute;
1194
+ }
1034
1195
  /**
1035
1196
  * Spawn a new Claude Code session in tmux.
1036
1197
  *
@@ -1109,6 +1270,39 @@ rm() { "${shimRunner}" rm "$@"; }
1109
1270
  // fallback model (separate quota bucket) instead of stalling. No-op +
1110
1271
  // zero disk I/O unless codex.rateLimitModelSwap is enabled. Best-effort.
1111
1272
  const launchModel = await this.resolveCodexLaunchModel(headlessFramework, options.model);
1273
+ // ── Headless-spawn reroute (june15-headless-spawn-reroute, PR2) ──
1274
+ // For claude-code ONLY, when subscriptionPath.mode is 'force' (or 'auto'
1275
+ // under SDK-pot pressure), reroute this `claude -p` one-shot onto the
1276
+ // interactive (subscription) lane so it stops billing the Agent SDK pot
1277
+ // after 2026-06-15. A control-flow fork, NOT a launch-spec swap (spec F3):
1278
+ // it delegates to the interactive delivery chain (ready-wait + inject) and
1279
+ // supplies its own completion sentinel + lifetime cap (the REPL never exits
1280
+ // on task success). Non-claude frameworks and 'off' fall straight through to
1281
+ // today's headless path, byte-for-byte.
1282
+ //
1283
+ // The cap + memory-pressure gate (spec O2/S1) is evaluated here: under
1284
+ // 'auto', an exceeded cap / elevated pressure DEGRADES to the headless lane
1285
+ // below (degradation-reported); under 'force' it REFUSES loudly (no headless
1286
+ // fallback — force guarantees zero `claude -p`). So this branch only commits
1287
+ // to the reroute when the gate passes; otherwise control falls through.
1288
+ if (await this.shouldRerouteToInteractive(headlessFramework)) {
1289
+ const gate = this.evaluateRerouteGate(options.name);
1290
+ if (gate.allow) {
1291
+ return await this.spawnReroutedInteractive({
1292
+ sessionId,
1293
+ tmuxSession,
1294
+ options,
1295
+ binaryPath: headlessBinaryPath,
1296
+ launchModel,
1297
+ resolvedCwd,
1298
+ workTreeFencingToken,
1299
+ shimDir,
1300
+ });
1301
+ }
1302
+ // gate refused — 'force' already threw inside evaluateRerouteGate; only
1303
+ // 'auto' reaches here, having degradation-reported. Fall through to the
1304
+ // headless build below.
1305
+ }
1112
1306
  const headlessSpec = buildHeadlessLaunch(headlessFramework, {
1113
1307
  binaryPath: headlessBinaryPath,
1114
1308
  prompt: options.prompt,
@@ -1245,10 +1439,278 @@ rm() { "${shimRunner}" rm "$@"; }
1245
1439
  framework: headlessFramework,
1246
1440
  prompt: options.prompt,
1247
1441
  maxDurationMinutes: options.maxDurationMinutes,
1442
+ // Positive-lane observability (june15-headless-spawn-reroute O4): always
1443
+ // populated now, so the soak's "zero headless under force" criterion is
1444
+ // machine-checkable from GET /sessions. completionMode stays 'exit' (the
1445
+ // headless one-shot signals completion by process exit — today's behavior).
1446
+ launchLane: 'headless',
1447
+ completionMode: 'exit',
1448
+ };
1449
+ this.state.saveSession(session);
1450
+ return session;
1451
+ }
1452
+ /**
1453
+ * Pre-spawn cap + memory-pressure gate for the reroute (june15-headless-spawn-
1454
+ * reroute PR2, findings O2/S1). Decides whether a rerouted-interactive spawn
1455
+ * may proceed:
1456
+ * - cap reached (>= subscriptionPath.maxRerouted) OR host memory pressure
1457
+ * elevated ('high'/'critical') → REFUSE.
1458
+ * - under 'force': a refusal THROWS loudly (force guarantees zero `claude -p`,
1459
+ * so there is no headless fallback) — and degradation-reports first.
1460
+ * - under 'auto': a refusal returns `{ allow: false }` after degradation-
1461
+ * reporting, so spawnSession falls through to the headless lane (the SDK
1462
+ * pot still works; the host stays healthy).
1463
+ * Returns `{ allow: true }` when the reroute may proceed.
1464
+ */
1465
+ evaluateRerouteGate(spawnName) {
1466
+ const mode = this.config.subscriptionPathMode ?? 'off';
1467
+ const maxRerouted = this.config.subscriptionMaxRerouted ?? 3;
1468
+ const reroutedCount = this.countReroutedInteractiveSessions();
1469
+ const pressure = this.currentMemoryPressure();
1470
+ const pressureElevated = pressure === 'high' || pressure === 'critical';
1471
+ if (reroutedCount < maxRerouted && !pressureElevated) {
1472
+ return { allow: true };
1473
+ }
1474
+ const why = reroutedCount >= maxRerouted
1475
+ ? `rerouted-session cap reached (${reroutedCount}/${maxRerouted})`
1476
+ : `host memory pressure is ${pressure}`;
1477
+ if (mode === 'force') {
1478
+ // Loud refusal — force-mode has no headless fallback. Report AND throw.
1479
+ DegradationReporter.getInstance().report({
1480
+ feature: 'SessionManager.spawnReroutedInteractive',
1481
+ primary: 'reroute claude-code one-shot onto the interactive subscription lane',
1482
+ fallback: 'spawn REFUSED (force-mode has no headless fallback)',
1483
+ reason: `Cannot reroute: ${why}`,
1484
+ impact: `Job/A2A spawn "${spawnName}" refused under force-mode to protect the host; it will retry on its next trigger`,
1485
+ });
1486
+ throw new Error(`Reroute refused (force-mode): ${why}`);
1487
+ }
1488
+ // 'auto' — degrade to the headless lane (host stays healthy). Caller falls
1489
+ // through to the headless build.
1490
+ DegradationReporter.getInstance().report({
1491
+ feature: 'SessionManager.spawnReroutedInteractive',
1492
+ primary: 'reroute claude-code one-shot onto the interactive subscription lane',
1493
+ fallback: 'fell back to the headless (claude -p) lane',
1494
+ reason: `Cannot reroute: ${why}`,
1495
+ impact: `Job/A2A spawn "${spawnName}" ran headless this time to avoid host pressure`,
1496
+ });
1497
+ this.recordRerouteFallback(why);
1498
+ return { allow: false };
1499
+ }
1500
+ /**
1501
+ * F6 recurrence cap (spec "Structural guards" + Distrust Temporary
1502
+ * Success): the auto-mode headless fallback is self-healing — a reroute
1503
+ * that's BROKEN (cap stuck, pressure stuck high, continuity gap) would
1504
+ * silently fall back on EVERY spawn and look fine forever while the entire
1505
+ * June-15 reroute is dead. Count fallbacks in a rolling window; past the
1506
+ * cap, raise ONE escalated degradation event per window saying "dead, not
1507
+ * transiently degraded".
1508
+ */
1509
+ rerouteFallbackTimes = [];
1510
+ rerouteFallbackEscalatedAt = 0;
1511
+ recordRerouteFallback(why) {
1512
+ const WINDOW_MS = 30 * 60_000;
1513
+ const CAP = 5;
1514
+ const now = Date.now();
1515
+ this.rerouteFallbackTimes = this.rerouteFallbackTimes.filter((t) => now - t < WINDOW_MS);
1516
+ this.rerouteFallbackTimes.push(now);
1517
+ if (this.rerouteFallbackTimes.length > CAP &&
1518
+ now - this.rerouteFallbackEscalatedAt >= WINDOW_MS) {
1519
+ this.rerouteFallbackEscalatedAt = now;
1520
+ DegradationReporter.getInstance().report({
1521
+ feature: 'SessionManager.rerouteFallbackRecurrence',
1522
+ primary: 'subscription-path reroute serving job/A2A spawns (mode auto)',
1523
+ fallback: `headless fallback recurred ${this.rerouteFallbackTimes.length}× in 30min — the reroute is effectively DEAD, not transiently degraded`,
1524
+ reason: `latest: ${why}`,
1525
+ impact: 'Every job/A2A spawn is back on the SDK-pot claude -p lane; post-June-15 this drains the pot with no reroute. Investigate the cap/pressure/continuity cause.',
1526
+ });
1527
+ }
1528
+ }
1529
+ /**
1530
+ * Spawn a claude-code job/A2A one-shot rerouted onto the interactive
1531
+ * (subscription) lane (june15-headless-spawn-reroute, PR2 "The core switch").
1532
+ *
1533
+ * This is the control-flow fork from spawnSession's headless branch. It:
1534
+ * - builds an INTERACTIVE claude launch (no `-p`; the REPL opens and waits)
1535
+ * via buildInteractiveLaunch, carrying the resolved model + the A2A
1536
+ * `--session-id`/`--resume` continuity flag through unchanged;
1537
+ * - splices the SAME `claudeHeadlessExtraFlags` (--allowedTools,
1538
+ * --strict-mcp-config --mcp-config {}) the headless path uses — both are
1539
+ * valid INTERACTIVE flags and the gate is framework-only, so the load-
1540
+ * bearing no-project-MCP boot (avoids the ~4.5-min OAuth-remote-MCP hang)
1541
+ * is preserved (spec F4/S3);
1542
+ * - creates the tmux session with the same env isolation as the headless
1543
+ * block PLUS `-x 200 -y 50` (prompt detection is tuned for the wide pane,
1544
+ * spec F6);
1545
+ * - enforces the memory-pressure + concurrency cap gate BEFORE rerouting
1546
+ * (spec O2/S1): under 'auto', exceeding either falls back to the headless
1547
+ * lane (degradation-reported); under 'force' the spawn is refused loudly;
1548
+ * - appends a deterministic completion sentinel to the prompt and stamps the
1549
+ * session with completionMode='pattern' + the per-session pattern + a hard
1550
+ * maxLifetimeMinutes, so the monitor loop can reap a never-exiting REPL on
1551
+ * sentinel-match or lifetime-expiry (spec O1, "the crux");
1552
+ * - fires the ready-wait + inject ASYNCHRONOUSLY (does NOT block the return —
1553
+ * callers expect the Session back promptly), reusing the same
1554
+ * waitForClaudeReadyWithRetry + injectMessage machinery handleReadyAndInject
1555
+ * uses.
1556
+ */
1557
+ async spawnReroutedInteractive(args) {
1558
+ const { sessionId, tmuxSession, options, binaryPath, launchModel, resolvedCwd, workTreeFencingToken, shimDir } = args;
1559
+ const lifetimeMinutes = this.config.subscriptionReroutedLifetimeMinutes ?? 45;
1560
+ // ── Completion sentinel (spec O1, "the crux") ──
1561
+ // An interactive REPL never exits on task success, and the default
1562
+ // completionPatterns are session-DEATH phrases. So we instruct the model to
1563
+ // print a deterministic, per-session sentinel as its FINAL line, and teach
1564
+ // the monitor loop to reap on that (completionMode='pattern'). The sentinel
1565
+ // is keyed on the session id suffix so two concurrent rerouted sessions
1566
+ // can't false-trigger each other.
1567
+ const sentinel = `INSTAR_JOB_COMPLETE_${sessionId.slice(-8)}`;
1568
+ const promptWithSentinel = `${options.prompt}\n\nWhen you have fully completed this task, print exactly this marker as your final line: ${sentinel}`;
1569
+ // ── Build the INTERACTIVE launch (no `-p`) ──
1570
+ // Carry the resolved model + the A2A continuity flag through. sessionId
1571
+ // (--session-id) pins the transcript at the caller-chosen uuid so A2A
1572
+ // `--resume` keeps working (spec "A2A cold spawns"); resumeSessionId
1573
+ // (--resume) reloads a prior transcript. Mirrors the headless precedence
1574
+ // (sessionId wins).
1575
+ const launchSpec = buildInteractiveLaunch('claude-code', {
1576
+ binaryPath,
1577
+ ...(launchModel ? { defaultModel: launchModel } : {}),
1578
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
1579
+ ...(!options.sessionId && options.resumeSessionId ? { resumeSessionId: options.resumeSessionId } : {}),
1580
+ });
1581
+ // Splice the load-bearing headless flags into the INTERACTIVE argv. Both
1582
+ // --allowedTools and --strict-mcp-config --mcp-config {} are valid
1583
+ // interactive flags; claudeHeadlessExtraFlags is gated only on
1584
+ // framework==='claude-code', so the reroute calls it and appends to the end
1585
+ // of the argv (the interactive argv has no `-p` positional to splice before).
1586
+ // Without --strict-mcp-config, an MCP-restricted rerouted job re-introduces
1587
+ // the documented ~4.5-min OAuth-remote-MCP boot hang (spec F4/S3).
1588
+ const extraClaudeFlags = claudeHeadlessExtraFlags({
1589
+ framework: 'claude-code',
1590
+ allowedTools: options.allowedTools,
1591
+ disableProjectMcp: options.disableProjectMcp,
1592
+ });
1593
+ if (extraClaudeFlags.length > 0) {
1594
+ launchSpec.argv.push(...extraClaudeFlags);
1595
+ }
1596
+ // K9: build PATH env with shim prepended (when shim was installed) — same as
1597
+ // the headless block.
1598
+ const inheritedPath = process.env.PATH ?? '';
1599
+ const shimmedPath = shimDir ? `${shimDir}:${inheritedPath}` : inheritedPath;
1600
+ const frameworkEnvFlags = [];
1601
+ for (const [k, v] of Object.entries(launchSpec.envOverrides)) {
1602
+ frameworkEnvFlags.push('-e', `${k}=${v}`);
1603
+ }
1604
+ // Anthropic-only env (claude-code reroute is always claude-code).
1605
+ const anthropicEnvFlags = (this.config.anthropicApiKey ?? '').startsWith('sk-ant-oat')
1606
+ ? ['-e', `CLAUDE_CODE_OAUTH_TOKEN=${this.config.anthropicApiKey}`, '-e', 'ANTHROPIC_API_KEY=']
1607
+ : ['-e', `ANTHROPIC_API_KEY=${this.config.anthropicApiKey ?? ''}`, '-e', 'CLAUDE_CODE_OAUTH_TOKEN='];
1608
+ anthropicEnvFlags.push('-e', `ANTHROPIC_BASE_URL=${this.config.anthropicBaseUrl ?? ''}`);
1609
+ try {
1610
+ execFileSync(this.config.tmuxPath, [
1611
+ 'new-session', '-d',
1612
+ '-s', tmuxSession,
1613
+ '-c', resolvedCwd,
1614
+ // Wide pane — detectClaudePrompt reads the last lines for the ❯/status
1615
+ // markers and is tuned for the interactive default (spec F6). The 80×24
1616
+ // headless default makes ready/idle detection flaky.
1617
+ '-x', '200', '-y', '50',
1618
+ ...frameworkEnvFlags,
1619
+ ...(this.config.claudeCodeMaxRetries != null
1620
+ ? ['-e', `CLAUDE_CODE_MAX_RETRIES=${this.config.claudeCodeMaxRetries}`]
1621
+ : []),
1622
+ '-e', `INSTAR_SESSION_ID=${sessionId}`,
1623
+ '-e', `INSTAR_SESSION_NAME=${tmuxSession}`,
1624
+ '-e', `INSTAR_SERVER_URL=http://localhost:${this.config.port}`,
1625
+ '-e', `INSTAR_AUTH_TOKEN=${this.config.authToken}`,
1626
+ '-e', `INSTAR_AGENT_ID=${this.config.projectName}`,
1627
+ ...(workTreeFencingToken ? ['-e', `INSTAR_FENCING_TOKEN=${workTreeFencingToken}`] : []),
1628
+ ...(workTreeFencingToken ? ['-e', `INSTAR_WORKTREE_PATH=${resolvedCwd}`] : []),
1629
+ ...(shimDir ? ['-e', `PATH=${shimmedPath}`, '-e', `BASH_ENV=${path.join(shimDir, '.shellrc')}`] : []),
1630
+ ...anthropicEnvFlags,
1631
+ // Same DB-credential isolation as every other spawn (Portal 2026-02-22).
1632
+ '-e', 'DATABASE_URL=',
1633
+ '-e', 'DIRECT_DATABASE_URL=',
1634
+ '-e', 'DATABASE_URL_PROD=',
1635
+ '-e', 'DATABASE_URL_DEV=',
1636
+ '-e', 'DATABASE_URL_TEST=',
1637
+ ...launchSpec.argv,
1638
+ ], { encoding: 'utf-8' });
1639
+ try {
1640
+ execFileSync(this.config.tmuxPath, [
1641
+ 'set-option', '-t', `=${tmuxSession}:`, 'history-limit', '50000',
1642
+ ], { encoding: 'utf-8', timeout: 5000 });
1643
+ }
1644
+ catch {
1645
+ // @silent-fallback-ok — history-limit is a nice-to-have
1646
+ }
1647
+ }
1648
+ catch (err) {
1649
+ throw new Error(`Failed to create rerouted interactive tmux session: ${err}`);
1650
+ }
1651
+ const session = {
1652
+ id: sessionId,
1653
+ name: options.name,
1654
+ status: 'running',
1655
+ jobSlug: options.jobSlug,
1656
+ tmuxSession,
1657
+ startedAt: new Date().toISOString(),
1658
+ triggeredBy: options.triggeredBy,
1659
+ model: resolveModelForFramework('claude-code', launchModel) ?? launchModel,
1660
+ framework: 'claude-code',
1661
+ prompt: options.prompt,
1662
+ maxDurationMinutes: options.maxDurationMinutes,
1663
+ // The reroute's completion contract (spec O1 + O4):
1664
+ launchLane: 'rerouted-interactive',
1665
+ completionMode: 'pattern',
1666
+ completionPatterns: [sentinel],
1667
+ maxLifetimeMinutes: lifetimeMinutes,
1248
1668
  };
1249
1669
  this.state.saveSession(session);
1670
+ // Deliver the prompt asynchronously — do NOT block spawnSession's return on
1671
+ // readiness (callers expect the Session back promptly; the ready-gate is
1672
+ // ~90s worst-case). Reuses the EXISTING ready-wait + inject machinery.
1673
+ const readyTimeout = options.resumeSessionId ? 120_000 : 90_000;
1674
+ this.injectAfterReady(tmuxSession, promptWithSentinel, readyTimeout).catch((err) => {
1675
+ // @silent-fallback-ok — fire-and-forget error log for the background
1676
+ // ready-wait+inject (mirrors handleReadyAndInject's .catch). injectAfterReady
1677
+ // already DegradationReporter-reports a genuine delivery failure internally;
1678
+ // this guards only against an unexpected rejection escaping the detached promise.
1679
+ console.error(`[SessionManager] Error during rerouted ready-and-inject for "${tmuxSession}": ${err}`);
1680
+ });
1250
1681
  return session;
1251
1682
  }
1683
+ /**
1684
+ * Wait for Claude to be ready, then inject a prompt — the minimal ready-wait
1685
+ * + inject path shared by the rerouted-interactive spawn
1686
+ * (june15-headless-spawn-reroute). Factored from handleReadyAndInject's shape;
1687
+ * deliberately WITHOUT the resume-fresh-spawn fallback (a rerouted job's
1688
+ * delivery is best-effort — the monitor loop's lifetime cap bounds a dead
1689
+ * REPL, and the job reruns cleanly on its next trigger).
1690
+ */
1691
+ async injectAfterReady(tmuxSession, prompt, readyTimeout) {
1692
+ const ready = await this.waitForClaudeReadyWithRetry(tmuxSession, readyTimeout);
1693
+ if (ready) {
1694
+ await new Promise((r) => setTimeout(r, 1000));
1695
+ this.injectMessage(tmuxSession, prompt);
1696
+ return;
1697
+ }
1698
+ // Not ready in time. If tmux is still alive, best-effort inject anyway
1699
+ // (prompt-detection false negative). If it's gone, the spawn crashed during
1700
+ // startup — surface a degradation; the monitor loop will not see a sentinel
1701
+ // and the lifetime cap will reap the record.
1702
+ if (this.tmuxSessionExists(tmuxSession)) {
1703
+ this.injectMessage(tmuxSession, prompt);
1704
+ return;
1705
+ }
1706
+ DegradationReporter.getInstance().report({
1707
+ feature: 'SessionManager.injectAfterReady',
1708
+ primary: 'wait for rerouted interactive Claude ready, inject prompt',
1709
+ fallback: 'tmux died during startup; prompt not delivered',
1710
+ reason: 'rerouted spawn crashed during startup; readiness probe could not verify prompt',
1711
+ impact: 'Rerouted job prompt dropped; the job reruns on its next trigger',
1712
+ });
1713
+ }
1252
1714
  /**
1253
1715
  * Check if a session is still running by checking tmux AND verifying
1254
1716
  * that the Claude process is running inside (not a zombie tmux pane).
@@ -1768,6 +2230,25 @@ rm() { "${shimRunner}" rm "$@"; }
1768
2230
  else
1769
2231
  skipped++; // not-lease-holder / protected — recorded, kept for next awake tick
1770
2232
  }
2233
+ else if (session.launchLane === 'rerouted-interactive' && session.jobSlug) {
2234
+ // Boot reconciliation (june15-headless-spawn-reroute, PR2 finding O3):
2235
+ // a rerouted JOB session that survived a restart is still ALIVE in tmux,
2236
+ // but JobScheduler.activeRunIds (in-memory) is lost AND triggerJob has no
2237
+ // guard against re-triggering the slug on its next cron tick → double
2238
+ // execution + double billing. Adopt-or-kill: we KILL (the safe choice —
2239
+ // the job reruns cleanly with fresh run-tracking; adopting an orphan REPL
2240
+ // whose run-id we no longer hold would silently never finalize). Operator
2241
+ // kill so the lease/KEEP-guards don't pin the orphan.
2242
+ const r = await this.terminateSession(session.id, 'boot-reconcile-rerouted-job', {
2243
+ finalStatus: 'killed',
2244
+ disposition: 'terminal',
2245
+ origin: 'operator',
2246
+ });
2247
+ if (r.terminated) {
2248
+ console.log(`[SessionManager] Startup reconcile: killed surviving rerouted job session "${session.name}" (slug ${session.jobSlug}) — the job reruns cleanly on its next trigger.`);
2249
+ }
2250
+ kept++; // counted as "not dead-purged"; the kill is the reconciliation
2251
+ }
1771
2252
  else {
1772
2253
  kept++;
1773
2254
  if (v?.liveness === 'indeterminate')
@@ -1827,15 +2308,9 @@ rm() { "${shimRunner}" rm "$@"; }
1827
2308
  const usedPercent = Math.round(((totalMem - freeMem) / totalMem) * 100);
1828
2309
  const freeMemMB = Math.round(freeMem / 1048576);
1829
2310
  const totalMemMB = Math.round(totalMem / 1048576);
1830
- let memoryPressure;
1831
- if (usedPercent >= 90)
1832
- memoryPressure = 'critical';
1833
- else if (usedPercent >= 75)
1834
- memoryPressure = 'high';
1835
- else if (usedPercent >= 60)
1836
- memoryPressure = 'moderate';
1837
- else
1838
- memoryPressure = 'low';
2311
+ // Shared with the reroute pre-spawn gate (june15-headless-spawn-reroute O2)
2312
+ // via currentMemoryPressure() one definition of the pressure tiers.
2313
+ const memoryPressure = this.currentMemoryPressure();
1839
2314
  // Build actionable suggestions
1840
2315
  const suggestions = [];
1841
2316
  if (staleSessions.length > 0) {
@@ -1875,6 +2350,25 @@ rm() { "${shimRunner}" rm "$@"; }
1875
2350
  return false;
1876
2351
  return this.config.completionPatterns.some(pattern => output.includes(pattern));
1877
2352
  }
2353
+ /**
2354
+ * Per-session completion detection (june15-headless-spawn-reroute, PR2).
2355
+ * Scans the captured pane for THIS session's own `completionPatterns` (the
2356
+ * injected sentinel) rather than the global death-phrase config. A rerouted-
2357
+ * interactive REPL never exits and never prints the global death phrases on
2358
+ * success, so the global detectCompletion would never reap it. Used by the
2359
+ * monitor loop ONLY for completionMode==='pattern' sessions; the default lane
2360
+ * is byte-for-byte untouched. Returns false when the session has no
2361
+ * per-session patterns (defensive — should not happen for a 'pattern' session).
2362
+ */
2363
+ detectSessionCompletion(session) {
2364
+ const patterns = session.completionPatterns;
2365
+ if (!patterns || patterns.length === 0)
2366
+ return false;
2367
+ const output = this.captureOutput(session.tmuxSession, 30);
2368
+ if (!output)
2369
+ return false;
2370
+ return patterns.some((pattern) => output.includes(pattern));
2371
+ }
1878
2372
  /**
1879
2373
  * Reap completed/zombie sessions.
1880
2374
  */
@@ -2040,7 +2534,15 @@ rm() { "${shimRunner}" rm "$@"; }
2040
2534
  envFramework: frameworkFromEnv(),
2041
2535
  });
2042
2536
  const binaryPath = this.config.frameworkBinaryPaths?.[framework]
2043
- ?? this.config.claudePath;
2537
+ ?? (framework === 'pi-cli'
2538
+ // pi-cli must NEVER inherit the claudePath fallback: claudePath holds a
2539
+ // CLAUDE binary on claude-code installs, and silently launching Claude
2540
+ // with pi's flags violates the additive-only constraint
2541
+ // (PI-HARNESS-INTEGRATION-SPEC §0.1). A bare 'pi' lets PATH resolution
2542
+ // work when detection missed an install; a missing binary then fails
2543
+ // loudly inside the pane instead of impersonating another framework.
2544
+ ? 'pi'
2545
+ : this.config.claudePath);
2044
2546
  if (!binaryPath) {
2045
2547
  throw new Error(`No binary path available for framework "${framework}"`);
2046
2548
  }
@@ -2061,6 +2563,12 @@ rm() { "${shimRunner}" rm "$@"; }
2061
2563
  ...(options?.codexLocalProvider ? { codexLocalProvider: options.codexLocalProvider } : {}),
2062
2564
  // Per-agent codex threadline MCP override (ignored by non-codex builders).
2063
2565
  ...(this.config.codexThreadlineMcp ? { codexThreadlineMcp: this.config.codexThreadlineMcp } : {}),
2566
+ // pi-cli: pin session transcripts into the agent state dir so they are
2567
+ // durable + discoverable (PI-HARNESS-INTEGRATION-SPEC §2.2). Ignored by
2568
+ // every other framework's builder.
2569
+ ...(framework === 'pi-cli'
2570
+ ? { piSessionDir: path.join(this.config.projectDir, '.instar', 'state', 'pi-sessions') }
2571
+ : {}),
2064
2572
  });
2065
2573
  // Spawn the framework CLI in tmux — no bash -c shell intermediary.
2066
2574
  // Uses tmux -e flags to set/unset env vars directly, matching spawnSession pattern.
@@ -2764,6 +3272,13 @@ rm() { "${shimRunner}" rm "$@"; }
2764
3272
  * Used by injectMessage after provenance checks pass.
2765
3273
  */
2766
3274
  rawInject(tmuxSession, text) {
3275
+ // Strip any EMBEDDED bracketed-paste markers from the prompt content before
3276
+ // we wrap it in our own \x1b[200~…\x1b[201~ (june15-headless-spawn-reroute,
3277
+ // PR2 finding S2). A job/A2A prompt containing a literal \x1b[201~ would
3278
+ // otherwise forge a paste boundary and submit extra REPL turns — the
3279
+ // headless `-p` argv path was immune, and InputGuard only scans topic-bound
3280
+ // sessions, so rerouted job/A2A prompts need this sanitizer at the chokepoint.
3281
+ text = text.replace(/\x1b\[20[01]~/g, '');
2767
3282
  // Reset idle-prompt timer — this session is about to receive new input,
2768
3283
  // so it's not a zombie. Without this, the zombie detector can kill a session
2769
3284
  // that just received a message but hasn't produced output yet.
@@ -2990,6 +3505,26 @@ rm() { "${shimRunner}" rm "$@"; }
2990
3505
  if (hasPromptChar && (line.includes(marker) || (lines[i + 1] && lines[i + 1].includes(shortMarker)))) {
2991
3506
  return true;
2992
3507
  }
3508
+ // pi has NO prompt char at all — its input box is a bare text line
3509
+ // SANDWICHED between two horizontal-rule lines (verified by live pane
3510
+ // capture, P0.1 eval pi 0.78.1):
3511
+ // ────────────────────────────
3512
+ // STUCK-MARKER-TEXT …
3513
+ // ────────────────────────────
3514
+ // Submitted transcript text is never between two rules, so requiring a
3515
+ // rule within 2 lines BOTH above and below (the tolerance covers a long
3516
+ // input wrapping to a second row) plus the marker match cannot
3517
+ // false-fire on normal output. Without this branch, an injected message
3518
+ // stranded in pi's input box would never be detected as stuck and the
3519
+ // Enter-recovery would never fire (the Gemini lesson, same shape).
3520
+ if (line.includes(marker) || (lines[i + 1] && lines[i + 1].includes(shortMarker))) {
3521
+ const isRule = (l) => !!l && /^\s*─{10,}\s*$/.test(l);
3522
+ const ruleAbove = isRule(lines[i - 1]) || isRule(lines[i - 2]);
3523
+ const ruleBelow = isRule(lines[i + 1]) || isRule(lines[i + 2]);
3524
+ if (ruleAbove && ruleBelow) {
3525
+ return true;
3526
+ }
3527
+ }
2993
3528
  }
2994
3529
  return false;
2995
3530
  }