@papi-ai/server 0.7.108 → 0.7.113

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 (3) hide show
  1. package/README.md +11 -0
  2. package/dist/index.js +325 -77
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -43,6 +43,17 @@ PAPI collects anonymous usage data (tool name, duration, project UUID — no cod
43
43
 
44
44
  PAPI is configured with `PAPI_PROJECT_ID` and `PAPI_DATA_API_KEY` — both are generated by the onboarding wizard at [getpapi.ai](https://getpapi.ai/) and pasted into your `.mcp.json`. If those env vars aren't set, PAPI will fall back to local file storage (md mode) and emit a stderr warning that your cycles aren't visible on the dashboard. To get on the dashboard, sign up at [getpapi.ai](https://getpapi.ai/) and use the config it gives you.
45
45
 
46
+ ### Stdio idle shutdown
47
+
48
+ The stdio server exits cleanly after two minutes without input, including its
49
+ database adapter pool, so abandoned client sessions do not accumulate. Set
50
+ `PAPI_STDIO_IDLE_TIMEOUT_MS=0` to disable this behavior, or provide another
51
+ non-negative millisecond value in the `.mcp.json` environment block. This
52
+ setting applies only to stdio mode; hosted HTTP instances are not idle-killed.
53
+ While `plan`, `handoff_generate`, or `strategy_review` is between its prepare
54
+ and apply calls, the server allows an additional bounded grace period for the
55
+ caller to finish the review instead of treating that intentional pause as idle.
56
+
46
57
  ## License
47
58
 
48
59
  [Elastic License 2.0](https://www.elastic.co/licensing/elastic-license) — free to use, self-host, and modify. Commercial hosting requires a license.
package/dist/index.js CHANGED
@@ -7075,7 +7075,7 @@ async function resolveMutationProject(input) {
7075
7075
  if (input.workspacePath) {
7076
7076
  if (!storedPapiDir) {
7077
7077
  throw new ProjectResolutionError(
7078
- `Project "${projectSlug}" has no stored workspace mapping. Refusing to write from "${input.workspacePath}" until the project is explicitly mapped.`
7078
+ `Project "${projectSlug}" has no stored workspace mapping. Refusing to write from "${input.workspacePath}" until the project is explicitly mapped. If your working directory belongs to a different project, run \`project_switch\` or pass \`project=<id>\` on the call.`
7079
7079
  );
7080
7080
  }
7081
7081
  try {
@@ -7087,7 +7087,7 @@ async function resolveMutationProject(input) {
7087
7087
  });
7088
7088
  if (pathResult?.action !== "ok") {
7089
7089
  throw new ProjectResolutionError(
7090
- `Project "${projectSlug}" has not been explicitly verified for this workspace. Refusing to write until its mapping is confirmed.`
7090
+ `Project "${projectSlug}" has not been explicitly verified for this workspace ("${input.workspacePath}"). Refusing to write until its mapping is confirmed. If this session is bound to the wrong project, run \`project_switch\` or pass \`project=<id>\` on the call.`
7091
7091
  );
7092
7092
  }
7093
7093
  } catch (err) {
@@ -7133,6 +7133,10 @@ async function resolveMutationProject(input) {
7133
7133
  function formatMutationScopeReceipt(scope) {
7134
7134
  const target = scope.targetProject ? `
7135
7135
  Target project: ${scope.targetProject.name} (${scope.targetProject.slug}, ${scope.targetProject.id})` : "";
7136
+ const workspaceUnverified = scope.workspacePath.startsWith("unavailable");
7137
+ const noMapping = /mapping=none/.test(scope.resolutionEvidence);
7138
+ const unverifiedNote = workspaceUnverified && noMapping ? `
7139
+ \u26A0 This project was bound from your MCP config with no workspace cross-check \u2014 PAPI cannot confirm it matches your working directory. If a write ever lands on the wrong board: pass \`project=<id>\` per call, run \`project_switch\`, or fix PAPI_PROJECT_ID / the x-papi-project-id header and reconnect.` : "";
7136
7140
  return [
7137
7141
  "---",
7138
7142
  "Project scope receipt",
@@ -7140,7 +7144,8 @@ Target project: ${scope.targetProject.name} (${scope.targetProject.slug}, ${scop
7140
7144
  `Repository evidence: ${scope.repositoryEvidence}`,
7141
7145
  `Workspace: ${scope.workspacePath}`,
7142
7146
  `Resolution: ${scope.resolutionEvidence}`,
7143
- target.trimStart()
7147
+ target.trimStart(),
7148
+ unverifiedNote.trimStart()
7144
7149
  ].filter(Boolean).join("\n");
7145
7150
  }
7146
7151
 
@@ -7558,9 +7563,10 @@ function resolveServerName(serverName) {
7558
7563
  const candidate = serverName?.trim();
7559
7564
  return candidate && SAFE_SERVER_NAME.test(candidate) ? candidate : DEFAULT_SERVER_NAME;
7560
7565
  }
7561
- function formatConnectionBanner(identity) {
7566
+ function formatConnectionBanner(identity, health = "connected") {
7562
7567
  const projectIdShort = identity.projectId ? ` (${identity.projectId.slice(0, 8)})` : "";
7563
- return `[papi] Connected \u2014 server: ${resolveServerName(identity.serverName)}, project: ${identity.projectName}${projectIdShort}, adapter: ${identity.adapterType}, v${identity.pkgVersion}`;
7568
+ const lead = health === "degraded" ? "Degraded \u2014 data may be stale" : health === "offline" ? "Offline \u2014 no database connection" : "Connected";
7569
+ return `[papi] ${lead} \u2014 server: ${resolveServerName(identity.serverName)}, project: ${identity.projectName}${projectIdShort}, adapter: ${identity.adapterType}, v${identity.pkgVersion}`;
7564
7570
  }
7565
7571
 
7566
7572
  // src/server.ts
@@ -11034,7 +11040,7 @@ function isBlockerResolved(blocker, ctx) {
11034
11040
  if (decision?.outcome && resolvedOutcomes.has(decision.outcome)) return true;
11035
11041
  if (decision?.resolutionState === "resolved") return true;
11036
11042
  const hasRecentEvent = ctx.decisionEvents.some(
11037
- (e) => idMatches(e.decisionId, blocker.ref) && e.cycle >= blocker.blockedCycle && !PROPOSAL_EVENT_TYPES.includes(e.eventType)
11043
+ (e) => idMatches(e.decisionId, blocker.ref) && e.cycle >= blocker.blockedCycle && !PROPOSAL_EVENT_TYPES.includes(e.eventType) && e.source !== "build_complete"
11038
11044
  );
11039
11045
  return hasRecentEvent;
11040
11046
  }
@@ -11240,6 +11246,20 @@ function determineContextTier(cycleCount) {
11240
11246
  if (cycleCount <= 20) return 2;
11241
11247
  return 3;
11242
11248
  }
11249
+ function trimContextForFirstCycle(ctx, cycleCount) {
11250
+ if (cycleCount !== 0 || ctx.mode === "bootstrap") return;
11251
+ ctx.cycleLog = "";
11252
+ ctx.recentBuildReports = "";
11253
+ const ad = ctx.activeDecisions;
11254
+ if (ad && ad.includes("[Confidence: ")) {
11255
+ const blocks = ad.split(/\n(?=### )/);
11256
+ const highOnly = blocks.filter(
11257
+ (b2) => !b2.startsWith("### ") || /\[Confidence: HIGH\]/.test(b2)
11258
+ );
11259
+ const trimmed = highOnly.join("\n").trim();
11260
+ ctx.activeDecisions = trimmed.length > 0 ? trimmed : ad;
11261
+ }
11262
+ }
11243
11263
  function applyContextTier(ctx, cycleCount) {
11244
11264
  const tier = determineContextTier(cycleCount);
11245
11265
  const label = tier === 1 ? "Tier 1 (cycles 1-5)" : tier === 2 ? "Tier 2 (cycles 6-20)" : "Tier 3 (cycles 21+)";
@@ -11993,6 +12013,7 @@ ${lines.join("\n")}`;
11993
12013
  siblingRepoWarning
11994
12014
  };
11995
12015
  const { label: leanTierLabel } = applyContextTier(ctx2, health.totalCycles);
12016
+ trimContextForFirstCycle(ctx2, health.totalCycles);
11996
12017
  ctx2.contextTier = leanTierLabel;
11997
12018
  console.error(`[plan-perf] context tier: ${leanTierLabel} (cycle ${health.totalCycles})`);
11998
12019
  t = startTimer();
@@ -12190,6 +12211,7 @@ ${logLines}`);
12190
12211
  siblingRepoWarning
12191
12212
  };
12192
12213
  const { label: fullTierLabel } = applyContextTier(ctx, health.totalCycles);
12214
+ trimContextForFirstCycle(ctx, health.totalCycles);
12193
12215
  ctx.contextTier = fullTierLabel;
12194
12216
  console.error(`[plan-perf] context tier: ${fullTierLabel} (cycle ${health.totalCycles})`);
12195
12217
  const prevHashes = contextHashesResultFull.status === "fulfilled" ? contextHashesResultFull.value : null;
@@ -12817,15 +12839,20 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
12817
12839
  for (const c of cycles) {
12818
12840
  if (!newestByNumber.has(c.number)) newestByNumber.set(c.number, c);
12819
12841
  }
12820
- const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber).filter((c) => opts.userId == null || c.userId === opts.userId);
12821
- if (blocking.length === 0) return [];
12842
+ const blockingAll = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber).filter((c) => opts.userId == null || c.userId === opts.userId);
12843
+ const phantoms = blockingAll.filter((c) => (c.goals?.length ?? 0) === 0);
12844
+ const blocking = blockingAll.filter((c) => (c.goals?.length ?? 0) > 0);
12845
+ const phantomNotes = phantoms.map(
12846
+ (c) => `Absorbed phantom active cycle ${c.number} (no goals \u2014 incomplete plan apply). Re-planning replaces it.`
12847
+ );
12848
+ if (blocking.length === 0) return phantomNotes;
12822
12849
  if (!opts.autoComplete) {
12823
12850
  const nums = blocking.map((c) => c.number).join(", ");
12824
12851
  throw new Error(
12825
12852
  `Cycle ${nums} is still active for this project \u2014 a second active cycle corrupts orient/build_execute. Run \`release\` to complete it first, or pass \`force: true\` to auto-complete it and continue.`
12826
12853
  );
12827
12854
  }
12828
- const notes = [];
12855
+ const notes = [...phantomNotes];
12829
12856
  for (const stale of blocking) {
12830
12857
  await adapter2.createCycle({
12831
12858
  ...stale,
@@ -12860,6 +12887,18 @@ async function validateAndPrepare(adapter2, force, callerUserId, adapterType) {
12860
12887
  const health = await adapter2.getCycleHealth();
12861
12888
  cycleNumber = health.totalCycles;
12862
12889
  mode = determineMode(health.projectCycleCount ?? health.totalCycles);
12890
+ try {
12891
+ const cyclesForPhantomCheck = await adapter2.readCycles();
12892
+ let callerNewest;
12893
+ for (const c of cyclesForPhantomCheck) {
12894
+ if (callerUserId && c.userId != null && c.userId !== callerUserId) continue;
12895
+ if (!callerNewest || c.number > callerNewest.number) callerNewest = c;
12896
+ }
12897
+ if (callerNewest && callerNewest.status === "active" && (callerNewest.goals?.length ?? 0) === 0 && callerNewest.number === cycleNumber) {
12898
+ cycleNumber = callerNewest.number - 1;
12899
+ }
12900
+ } catch {
12901
+ }
12863
12902
  const blockingCycle = callerUserId ? await resolveCallerLatestCycle(adapter2, callerUserId) : void 0;
12864
12903
  const latestStatus = callerUserId ? blockingCycle?.status : health.latestCycleStatus;
12865
12904
  const blockingNumber = blockingCycle?.number ?? cycleNumber;
@@ -12950,6 +12989,11 @@ function assertApplyPayloadNonEmpty(data, cycleNumber) {
12950
12989
  `Plan apply rejected: trimmed/partial apply payload \u2014 nothing persisted. The parsed plan for Cycle ${cycleNumber + 1} assigns NO tasks to the cycle (cycleHandoffs, cycleTaskIds and newTasks are all empty). Writing it would create a cycle row with zero task membership and no handoffs \u2014 the phantom-empty-cycle failure mode that renders an empty Team view. The most likely cause is a truncated apply JSON: the prepare payload overflowed the client tool-result ceiling (see task-2905/2906). Re-run the plan and resend the COMPLETE structured output \u2014 ensure the JSON after the <!-- PAPI_STRUCTURED_OUTPUT --> marker includes the full cycleHandoffs array (or cycleTaskIds when using skip_handoffs).`
12951
12990
  );
12952
12991
  }
12992
+ if (!data.cycleLogTitle || data.cycleLogTitle.trim().length === 0) {
12993
+ throw new Error(
12994
+ `Plan apply rejected: trimmed/partial apply payload \u2014 nothing persisted. The parsed plan for Cycle ${cycleNumber + 1} has no cycleLogTitle, so the cycle row would be written with empty goals \u2014 the phantom-cycle failure mode that blocks the next plan and renders nowhere on the hub. Re-run the plan and resend the COMPLETE structured output including cycleLogTitle.`
12995
+ );
12996
+ }
12953
12997
  }
12954
12998
  var VALID_PLAN_PRIORITIES = /* @__PURE__ */ new Set(["P0 Critical", "P1 High", "P2 Medium", "P3 Low"]);
12955
12999
  function assertPlanPrioritiesValid(data) {
@@ -13086,7 +13130,7 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
13086
13130
  decisionConflicts
13087
13131
  };
13088
13132
  }
13089
- async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker, density) {
13133
+ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker, density, taskIds) {
13090
13134
  const prepareTimer = startTimer();
13091
13135
  tracker?.mark("validate_and_prepare");
13092
13136
  let t = startTimer();
@@ -13114,10 +13158,43 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
13114
13158
  tracker?.mark("handoffs_only_assemble");
13115
13159
  t = startTimer();
13116
13160
  const targetCycle = cycleNumber + 1;
13117
- const allTasks = await adapter2.queryBoard({ status: ["Backlog", "In Cycle", "Ready", "In Progress"] });
13118
- const preAssigned = allTasks.filter((task) => task.cycle === targetCycle);
13119
- if (preAssigned.length === 0) {
13120
- throw new Error(`No tasks assigned to Cycle ${targetCycle}. Assign tasks first (set cycle = ${targetCycle} via SQL) or run plan without handoffs_only.`);
13161
+ const allTasks = await adapter2.queryBoard({ status: ["Backlog", "In Cycle", "Ready", "In Progress", "In Review", "Blocked"] });
13162
+ let preAssigned;
13163
+ if (taskIds && taskIds.length > 0) {
13164
+ const byDisplayId = new Map(allTasks.map((t2) => [t2.displayId ?? t2.id, t2]));
13165
+ const byId = new Map(allTasks.map((t2) => [t2.id, t2]));
13166
+ const BUILDABLE = /* @__PURE__ */ new Set(["Backlog", "In Cycle", "Ready"]);
13167
+ const resolved = [];
13168
+ const rejected = [];
13169
+ const seen = /* @__PURE__ */ new Set();
13170
+ for (const raw of taskIds) {
13171
+ const task = byDisplayId.get(raw) ?? byId.get(raw);
13172
+ if (!task) {
13173
+ rejected.push(`${raw}: not a task in this project (or already Done/Cancelled/Deferred)`);
13174
+ continue;
13175
+ }
13176
+ if (seen.has(task.id)) continue;
13177
+ seen.add(task.id);
13178
+ if (!BUILDABLE.has(task.status)) {
13179
+ rejected.push(`${raw}: status is "${task.status}" \u2014 only Backlog / In Cycle / Ready tasks can be planned`);
13180
+ continue;
13181
+ }
13182
+ resolved.push(task);
13183
+ }
13184
+ if (rejected.length > 0) {
13185
+ throw new Error(
13186
+ `Cycle ${targetCycle} \u2014 nothing was applied. ${rejected.length} of ${taskIds.length} task id(s) could not be planned:
13187
+ ` + rejected.map((r) => ` - ${r}`).join("\n") + `
13188
+
13189
+ Fix the ids and re-run. Every id must be a currently-buildable task (Backlog / In Cycle / Ready) in this project.`
13190
+ );
13191
+ }
13192
+ preAssigned = resolved;
13193
+ } else {
13194
+ preAssigned = allTasks.filter((task) => task.cycle === targetCycle);
13195
+ if (preAssigned.length === 0) {
13196
+ throw new Error(`No tasks assigned to Cycle ${targetCycle}. Assign tasks first (set cycle = ${targetCycle} via SQL) or run plan without handoffs_only.`);
13197
+ }
13121
13198
  }
13122
13199
  const [decisions, reports, brief] = await Promise.all([
13123
13200
  adapter2.getActiveDecisions(),
@@ -13759,6 +13836,37 @@ function planDelivery(input) {
13759
13836
  }
13760
13837
  }
13761
13838
 
13839
+ // src/lib/harness-capability.ts
13840
+ var HARNESS_REGISTRY = {
13841
+ // Local stdio CLI agents — PAPI runs git on the user's machine. Confirmed in telemetry.
13842
+ "claude-code": { build: true, label: "Claude Code" },
13843
+ "opencode": { build: true, label: "opencode" },
13844
+ "zcode": { build: true, label: "zcode" },
13845
+ "codex": { build: true, label: "Codex" },
13846
+ "cursor": { build: true, label: "Cursor" },
13847
+ // Cloud harnesses with code sandboxes — their agent builds in-sandbox over HTTP+OAuth.
13848
+ "lovable": { build: true, label: "Lovable" },
13849
+ "bolt": { build: true, label: "Bolt" },
13850
+ "replit": { build: true, label: "Replit" },
13851
+ // Chat-only surfaces, no sandbox — planning only.
13852
+ "chatgpt": { build: false, label: "ChatGPT" },
13853
+ "claude.ai": { build: false, label: "Claude.ai" },
13854
+ "claude-desktop": { build: false, label: "Claude Desktop" }
13855
+ };
13856
+ var UNKNOWN_DEFAULT = { build: false, label: "your tool" };
13857
+ function detectHarness(clientName) {
13858
+ const raw = clientName?.trim() || null;
13859
+ if (!raw) {
13860
+ return { ...UNKNOWN_DEFAULT, raw: null, key: null, known: false };
13861
+ }
13862
+ const norm = raw.toLowerCase();
13863
+ const key = HARNESS_REGISTRY[norm] ? norm : Object.keys(HARNESS_REGISTRY).find((k) => norm.includes(k)) ?? null;
13864
+ if (!key) {
13865
+ return { ...UNKNOWN_DEFAULT, label: raw, raw, key: null, known: false };
13866
+ }
13867
+ return { ...HARNESS_REGISTRY[key], raw, key, known: true };
13868
+ }
13869
+
13762
13870
  // src/services/session-guidance.ts
13763
13871
  var DEFAULT_CALLER_KEY = "__default__";
13764
13872
  var sessionStates = /* @__PURE__ */ new Map();
@@ -13835,7 +13943,7 @@ function markOrient(callerKey) {
13835
13943
  }
13836
13944
  function getProjectConnectionBanner(projectName, projectSlug) {
13837
13945
  if (!projectName || !projectSlug) return null;
13838
- return `[Connected: ${projectName} (${projectSlug})] \u2014 confirm this is the project you mean before I write to it. If it's wrong, don't proceed: pass \`project=<id>\` on the call to target a different project, or fix the project id in your MCP config (PAPI_PROJECT_ID for local, x-papi-project-id header for remote) and reconnect.`;
13946
+ return `**\u26A0 Bound to: ${projectName} (${projectSlug})** \u2014 every write on this session lands on THIS project's board. If that is not the project your working directory belongs to, stop: pass \`project=<id>\` on the call to target a different project, run \`project_switch\` to rebind the session, or fix the project id in your MCP config (PAPI_PROJECT_ID for local, x-papi-project-id header for remote) and reconnect.`;
13839
13947
  }
13840
13948
  function detectContextDegradation(now = Date.now(), callerKey) {
13841
13949
  const state = getState(callerKey);
@@ -14018,6 +14126,9 @@ function savePrepareContextFile(projectId, callerKey, content) {
14018
14126
 
14019
14127
  // src/tools/plan.ts
14020
14128
  var planPrepareCache = new PerCallerCache();
14129
+ function hasPendingPlanPrepare() {
14130
+ return planPrepareCache.size() > 0;
14131
+ }
14021
14132
  var planTool = {
14022
14133
  name: "plan",
14023
14134
  description: 'Turn a backlog into one scoped cycle of work, with a written spec for every task in it. plan reads the whole board, the decisions this project has already taken, and how much it actually delivered in recent cycles, then prioritises what to do next and writes a per-task BUILD HANDOFF: scope, what is deliberately out of scope, acceptance criteria, files likely touched, security notes, and the shared branch each task belongs on. It also reports board health, so stale, blocked and drifting work is visible instead of accumulating. This is not the same as planning inside one session: the cycle, the specs and the reasoning are stored, so the next session, the next tool, or the next person picks up where this one stopped. Run once per cycle, after setup the first time, or after completing all builds AND running release for the previous cycle. NEVER call when unbuilt cycle tasks exist: build and release first. First call returns a planning prompt for you to execute (prepare phase). Then call again with mode "apply" and your output to write results. Use skip_handoffs=true for large backlogs, and generate handoffs separately via `handoff_generate`.',
@@ -14080,6 +14191,11 @@ var planTool = {
14080
14191
  type: "boolean",
14081
14192
  description: "Skip backlog analysis and task selection. Only generate BUILD HANDOFFs for tasks already assigned to the target cycle. Requires pre-assigned tasks (set cycle number on tasks first). ~30% of normal plan cost."
14082
14193
  },
14194
+ task_ids: {
14195
+ type: "array",
14196
+ items: { type: "string" },
14197
+ description: 'Plan EXACTLY these backlog tasks into the cycle, with BUILD HANDOFFs, via the same atomic apply as a normal plan. Use when the user says "take these tasks and get them build-ready" or "plan task-12, task-15, task-19" \u2014 do NOT reach for ad_hoc (a receipt) or board_edit (strands them with no handoff). Skips backlog selection: only the named tasks are scoped. Every id must be a real, currently-buildable task (Backlog / In Cycle / Ready) in this project \u2014 unknown or ineligible ids are rejected with a per-id reason and nothing is applied. The apply assigns cycle + handoff + status together, so a failure leaves nothing half-landed.'
14198
+ },
14083
14199
  skip_handoffs: {
14084
14200
  type: "boolean",
14085
14201
  description: "Run full planning (triage, task selection, board management) but skip BUILD HANDOFF generation. Selected tasks are assigned to the cycle without handoffs. Run `handoff_generate` after to create handoffs separately. Reduces planner cognitive load for large backlogs."
@@ -14189,7 +14305,7 @@ function formatPlanResult(result) {
14189
14305
  }
14190
14306
  return response;
14191
14307
  }
14192
- async function handlePlan(adapter2, config2, args) {
14308
+ async function handlePlan(adapter2, config2, args, clientName) {
14193
14309
  const toolMode = args.mode;
14194
14310
  const callerKey = callerKeyFromConfig(config2);
14195
14311
  const filters = {};
@@ -14199,7 +14315,8 @@ async function handlePlan(adapter2, config2, args) {
14199
14315
  if (typeof args.priority === "string") filters.priority = args.priority;
14200
14316
  const focus = typeof args.focus === "string" ? args.focus : void 0;
14201
14317
  const force = args.force === true;
14202
- const handoffsOnly = args.handoffs_only === true;
14318
+ const taskIds = Array.isArray(args.task_ids) ? args.task_ids.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim()) : void 0;
14319
+ const handoffsOnly = args.handoffs_only === true || taskIds !== void 0 && taskIds.length > 0;
14203
14320
  const density = args.density === "light" || args.density === "standard" || args.density === "deep" ? args.density : void 0;
14204
14321
  const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate").bindStream(adapter2, { stage: "plan" });
14205
14322
  try {
@@ -14281,7 +14398,7 @@ async function handlePlan(adapter2, config2, args) {
14281
14398
  } catch {
14282
14399
  }
14283
14400
  const skipHandoffs = args.skip_handoffs === true;
14284
- const result = await preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker, density);
14401
+ const result = await preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker, density, taskIds);
14285
14402
  const prepareState = {
14286
14403
  contextHashes: result.contextHashes,
14287
14404
  userMessage: result.userMessage,
@@ -14292,7 +14409,15 @@ async function handlePlan(adapter2, config2, args) {
14292
14409
  planPrepareCache.set(callerKey, prepareState);
14293
14410
  savePrepareSpill(adapter2.getProjectId?.(), callerKey, prepareState);
14294
14411
  const explicit = args.dispatch === "inline" ? false : args.dispatch === "subagent" ? true : void 0;
14295
- const dispatch = shouldDispatch(result.contextBytes ?? 0, explicit) ? "subagent" : "inline";
14412
+ let dispatch = shouldDispatch(result.contextBytes ?? 0, explicit) ? "subagent" : "inline";
14413
+ let autoRouteDowngraded = false;
14414
+ if (dispatch === "subagent" && explicit === void 0) {
14415
+ const harness = detectHarness(clientName);
14416
+ if (harness.key !== "claude-code") {
14417
+ dispatch = "inline";
14418
+ autoRouteDowngraded = true;
14419
+ }
14420
+ }
14296
14421
  const modeLabel = result.mode === "bootstrap" ? "Bootstrap" : "Full";
14297
14422
  const header = result.strategyReviewWarning ? `${result.strategyReviewWarning}
14298
14423
  ` : "";
@@ -14328,7 +14453,12 @@ ${result.userMessage}
14328
14453
  const dispatchHeader = result.strategyReviewWarning ? `${result.strategyReviewWarning}
14329
14454
 
14330
14455
  ` : "";
14331
- return { ...textResponse(`${dispatchHeader}${dispatchPrompt}`), _contextBytes: result.contextBytes };
14456
+ const inlineOverride = `
14457
+
14458
+ ---
14459
+
14460
+ > **No sub-agent or \`Task\` primitive in your client?** Re-call \`plan\` with \`dispatch: "inline"\` to get the planning prompt back directly, then run it yourself in a fresh context and pass the output to the apply call. The apply contract is identical either way.`;
14461
+ return { ...textResponse(`${dispatchHeader}${dispatchPrompt}${inlineOverride}`), _contextBytes: result.contextBytes };
14332
14462
  }
14333
14463
  if (contextFilePath) {
14334
14464
  const kb = result.contextBytes !== void 0 ? ` (~${(result.contextBytes / 1024).toFixed(0)} KB)` : "";
@@ -14350,8 +14480,11 @@ The full planning brief \u2014 system prompt + all context${kb} \u2014 has been
14350
14480
  );
14351
14481
  return { ...pathResponse, _contextBytes: result.contextBytes };
14352
14482
  }
14483
+ const downgradeNote = autoRouteDowngraded ? `> This context is large. It is returned inline because your client has no \`Task\` sub-agent primitive. If your client CAN run a sub-agent, re-call \`plan\` with \`dispatch: "subagent"\`.
14484
+
14485
+ ` : "";
14353
14486
  const response = textResponse(
14354
- `${header}## PAPI Cycle Plan \u2014 Prepare Phase (${modeLabel} Mode, Cycle ${result.cycleNumber + 1})
14487
+ `${header}${downgradeNote}## PAPI Cycle Plan \u2014 Prepare Phase (${modeLabel} Mode, Cycle ${result.cycleNumber + 1})
14355
14488
 
14356
14489
  Follow the system prompt and context below to generate a complete cycle plan.
14357
14490
 
@@ -17103,6 +17236,9 @@ function buildStrategyReviewPostDirective(cycleNumber) {
17103
17236
  ].join("\n");
17104
17237
  }
17105
17238
  var reviewPrepareCache = new PerCallerCache();
17239
+ function hasPendingStrategyReview() {
17240
+ return reviewPrepareCache.size() > 0;
17241
+ }
17106
17242
  var strategyReviewTool = {
17107
17243
  name: "strategy_review",
17108
17244
  description: 'Run a Strategy Review \u2014 assesses project direction, velocity, and Active Decisions. Produces recommendations and potential AD updates that feed into the next plan. Offered every 5 cycles; hard-blocked at 7+ overdue cycles. Run it in your current conversation \u2014 only start a fresh one if you are genuinely under context pressure (your host just compacted, you are near the context limit, or the session is heavy with build context), not just because a review is next. First call returns a review prompt for you to execute (prepare phase). Then call again with mode "apply" and your output. Pass `force: true` to run before the cadence gate.',
@@ -17557,6 +17693,7 @@ ${result.userMessage}
17557
17693
  }
17558
17694
 
17559
17695
  // src/lib/task-readiness.ts
17696
+ var ADVISORY_GAP_LABELS = /* @__PURE__ */ new Set(["WHY NOW", "FILES LIKELY TOUCHED"]);
17560
17697
  function hasMeaningfulEntry(value) {
17561
17698
  return Array.isArray(value) && value.some((entry) => {
17562
17699
  if (typeof entry === "string") return entry.trim().length > 0;
@@ -17576,23 +17713,29 @@ function getHandoffGaps(handoff) {
17576
17713
  return gaps;
17577
17714
  }
17578
17715
  function getTaskReadiness(task, currentCycle2) {
17579
- const handoffGaps = getHandoffGaps(task.buildHandoff);
17580
- const handoffComplete = handoffGaps.length === 0;
17716
+ const allGaps = getHandoffGaps(task.buildHandoff);
17717
+ const blockingGaps = allGaps.filter((g) => !ADVISORY_GAP_LABELS.has(g));
17718
+ const advisoryGaps = allGaps.filter((g) => ADVISORY_GAP_LABELS.has(g));
17719
+ const handoffGaps = [...blockingGaps, ...advisoryGaps];
17720
+ const handoffComplete = blockingGaps.length === 0;
17721
+ const advisoryNote = advisoryGaps.length > 0 ? ` A planner should still fill ${advisoryGaps.join(", ")}, but it does not block the build.` : "";
17581
17722
  const cycleMismatch = currentCycle2 != null && currentCycle2 > 0 && task.cycle !== currentCycle2;
17582
17723
  if (task.status === "Backlog") {
17583
17724
  if (!handoffComplete) {
17584
17725
  return {
17585
17726
  label: "Needs Planning",
17586
- reason: `No complete BUILD HANDOFF (${handoffGaps.join(", ")}). Run plan before claiming or building this task.`,
17727
+ reason: `No complete BUILD HANDOFF (${blockingGaps.join(", ")}). Run plan before claiming or building this task.`,
17587
17728
  handoffComplete,
17588
- handoffGaps
17729
+ handoffGaps,
17730
+ advisoryGaps
17589
17731
  };
17590
17732
  }
17591
17733
  return {
17592
17734
  label: "Needs Planning",
17593
17735
  reason: "Backlog work is waiting for a planning pass to assign it to a cycle before it can be built.",
17594
17736
  handoffComplete,
17595
- handoffGaps
17737
+ handoffGaps,
17738
+ advisoryGaps
17596
17739
  };
17597
17740
  }
17598
17741
  if (task.status === "In Cycle" || task.status === "Ready") {
@@ -17601,22 +17744,25 @@ function getTaskReadiness(task, currentCycle2) {
17601
17744
  label: "Needs Planning",
17602
17745
  reason: `Assigned to Cycle ${task.cycle ?? "an earlier cycle"}, not the active Cycle ${currentCycle2}. Run plan before building it.`,
17603
17746
  handoffComplete,
17604
- handoffGaps
17747
+ handoffGaps,
17748
+ advisoryGaps
17605
17749
  };
17606
17750
  }
17607
17751
  if (!handoffComplete) {
17608
17752
  return {
17609
17753
  label: "Needs Planning",
17610
- reason: `Planning has not produced a complete BUILD HANDOFF (${handoffGaps.join(", ")}).`,
17754
+ reason: `Planning has not produced a complete BUILD HANDOFF (${blockingGaps.join(", ")}).`,
17611
17755
  handoffComplete,
17612
- handoffGaps
17756
+ handoffGaps,
17757
+ advisoryGaps
17613
17758
  };
17614
17759
  }
17615
17760
  return {
17616
17761
  label: "Ready to Build",
17617
- reason: "Task is assigned to the active cycle and has a complete BUILD HANDOFF.",
17762
+ reason: `Task is assigned to the active cycle and has a complete BUILD HANDOFF.${advisoryNote}`,
17618
17763
  handoffComplete,
17619
- handoffGaps
17764
+ handoffGaps,
17765
+ advisoryGaps
17620
17766
  };
17621
17767
  }
17622
17768
  const labelByStatus = {
@@ -17632,7 +17778,8 @@ function getTaskReadiness(task, currentCycle2) {
17632
17778
  label,
17633
17779
  reason: label === "Needs Planning" ? "Task is not in a buildable lifecycle state." : `Task status is ${task.status}.`,
17634
17780
  handoffComplete,
17635
- handoffGaps
17781
+ handoffGaps,
17782
+ advisoryGaps
17636
17783
  };
17637
17784
  }
17638
17785
 
@@ -18243,6 +18390,20 @@ async function handleBoardView(adapter2, args) {
18243
18390
  fields,
18244
18391
  membership.isMultiMember ? assigneeColumn(membership.nameOf) : null
18245
18392
  );
18393
+ if (typeof args.cycle === "number") {
18394
+ try {
18395
+ const cycleRows = await adapter2.readCycles();
18396
+ const row = cycleRows.find((c) => c.number === args.cycle);
18397
+ const listed = row?.taskIds?.length ?? 0;
18398
+ const carrying = result.tasks.filter((t) => t.cycle === args.cycle).length;
18399
+ if (row && listed > 0 && listed !== carrying) {
18400
+ output += `
18401
+
18402
+ > \u26A0\uFE0F Cycle ${args.cycle} membership disagrees: the cycle row lists ${listed} task(s) but ${carrying} carry cycle=${args.cycle}. This view shows the latter. Run \`board_reconcile\` to realign them.`;
18403
+ }
18404
+ } catch {
18405
+ }
18406
+ }
18246
18407
  if (!fields) {
18247
18408
  try {
18248
18409
  const comments = await adapter2.getRecentTaskComments?.(30);
@@ -20962,37 +21123,6 @@ function pendingDecisionBlocker(attempts, cycle, taskId) {
20962
21123
  };
20963
21124
  }
20964
21125
 
20965
- // src/lib/harness-capability.ts
20966
- var HARNESS_REGISTRY = {
20967
- // Local stdio CLI agents — PAPI runs git on the user's machine. Confirmed in telemetry.
20968
- "claude-code": { build: true, label: "Claude Code" },
20969
- "opencode": { build: true, label: "opencode" },
20970
- "zcode": { build: true, label: "zcode" },
20971
- "codex": { build: true, label: "Codex" },
20972
- "cursor": { build: true, label: "Cursor" },
20973
- // Cloud harnesses with code sandboxes — their agent builds in-sandbox over HTTP+OAuth.
20974
- "lovable": { build: true, label: "Lovable" },
20975
- "bolt": { build: true, label: "Bolt" },
20976
- "replit": { build: true, label: "Replit" },
20977
- // Chat-only surfaces, no sandbox — planning only.
20978
- "chatgpt": { build: false, label: "ChatGPT" },
20979
- "claude.ai": { build: false, label: "Claude.ai" },
20980
- "claude-desktop": { build: false, label: "Claude Desktop" }
20981
- };
20982
- var UNKNOWN_DEFAULT = { build: false, label: "your tool" };
20983
- function detectHarness(clientName) {
20984
- const raw = clientName?.trim() || null;
20985
- if (!raw) {
20986
- return { ...UNKNOWN_DEFAULT, raw: null, key: null, known: false };
20987
- }
20988
- const norm = raw.toLowerCase();
20989
- const key = HARNESS_REGISTRY[norm] ? norm : Object.keys(HARNESS_REGISTRY).find((k) => norm.includes(k)) ?? null;
20990
- if (!key) {
20991
- return { ...UNKNOWN_DEFAULT, label: raw, raw, key: null, known: false };
20992
- }
20993
- return { ...HARNESS_REGISTRY[key], raw, key, known: true };
20994
- }
20995
-
20996
21126
  // src/lib/harness-build-steps.ts
20997
21127
  init_git();
20998
21128
  function startBuildSteps(branch, base, taskId) {
@@ -25566,6 +25696,9 @@ init_git();
25566
25696
 
25567
25697
  // src/tools/handoff.ts
25568
25698
  var handoffPrepareCache = new PerCallerCache();
25699
+ function hasPendingHandoffGenerate() {
25700
+ return handoffPrepareCache.size() > 0;
25701
+ }
25569
25702
  var handoffGenerateTool = {
25570
25703
  name: "handoff_generate",
25571
25704
  description: "Generate BUILD HANDOFFs for cycle tasks that don't have one yet. Run after `plan` (with skip_handoffs=true) or to regenerate stale handoffs. Uses the prepare/apply pattern \u2014 first call returns a prompt, second call persists results.",
@@ -31770,10 +31903,14 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
31770
31903
  lines.push("Board is empty \u2014 run `plan` to create your next cycle.");
31771
31904
  }
31772
31905
  lines.push("");
31906
+ lines.push(WORK_DUMP_ROUTING_RULE);
31907
+ lines.push("");
31773
31908
  } else if (buildInfo.noHandoffs) {
31774
31909
  lines.push("## Tasks");
31775
31910
  lines.push("No tasks with BUILD HANDOFFs \u2014 run `plan` to generate cycle tasks.");
31776
31911
  lines.push("");
31912
+ lines.push(WORK_DUMP_ROUTING_RULE);
31913
+ lines.push("");
31777
31914
  } else {
31778
31915
  lines.push(`## Tasks with Handoffs`);
31779
31916
  lines.push(`${buildInfo.totalHandoffs} total \u2014 ${buildInfo.inProgress.length} in progress, ${buildInfo.backlogCount} backlog, ${buildInfo.blockedCount} blocked`);
@@ -31804,6 +31941,7 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
31804
31941
  return lines.join("\n").trimEnd();
31805
31942
  }
31806
31943
  var NOT_SET_UP_MESSAGE = "Setup required \u2014 this project has no PAPI state yet. That is the normal starting point, not an error. Run `setup` to generate your Product Brief and scaffold the workflow, then `plan` to create your first cycle.";
31944
+ var WORK_DUMP_ROUTING_RULE = '_If someone hands you a list of work (an audit, a bug list, "build all of these"), do NOT start writing code. File each item with `idea`, then run `plan` to scope a cycle \u2014 that is how work gets a spec, a branch and a review._';
31807
31945
  function emptyHealthSummary() {
31808
31946
  return {
31809
31947
  cycleNumber: 0,
@@ -32159,12 +32297,34 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
32159
32297
  cycleSince: currentCycle2,
32160
32298
  compact: true
32161
32299
  }).catch(() => []) : [];
32162
- const cycleTasksAll = scopeTasksToCaller(cycleTasksAllRaw, callerUserId);
32163
- const cycleCounts = computeCycleTaskCounts(cycleTasksAll, currentCycle2, cycleIsComplete);
32300
+ const cycleCounts = computeCycleTaskCounts(cycleTasksAllRaw, currentCycle2, cycleIsComplete);
32164
32301
  const { inProgress: cycleInProgress, inReview: cycleInReview, inCycle: cycleInCycle, ready: cycleReady, backlog: cycleBacklog, total: cycleTotal, done: cycleDone } = cycleCounts;
32165
32302
  if (!cycleIsComplete && cycleTotal === 0 && cycleDone > 0) {
32166
32303
  buildResult.warnings.unshift(`\u26A0\uFE0F Cycle ${currentCycle2} is complete \u2014 all ${cycleDone} task${cycleDone !== 1 ? "s" : ""} Done. Release has not been run. Run \`release\` now (or \`release\` with \`skipVersion=true\` to close the cycle without a git tag).`);
32167
32304
  }
32305
+ if (!cycleIsComplete && currentCycle2 > 0) {
32306
+ try {
32307
+ const cyclesForPhantom = await adapter2.readCycles();
32308
+ const mine = cyclesForPhantom.filter(
32309
+ (c) => c.number === currentCycle2 && (!callerUserId || c.userId == null || c.userId === callerUserId)
32310
+ );
32311
+ const phantom = mine.find((c) => c.status === "active" && (c.goals?.length ?? 0) === 0);
32312
+ if (phantom) {
32313
+ buildResult.warnings.unshift(
32314
+ `\u26A0\uFE0F Cycle ${currentCycle2} has no goals and looks like a plan that didn't finish writing. Run \`plan\` again \u2014 it will replace this incomplete cycle rather than stacking a new one on top. No data is lost: nothing has been built in it.`
32315
+ );
32316
+ }
32317
+ const cycleRow = mine.find((c) => c.status === "active") ?? mine[0];
32318
+ const taskIdMembership = cycleRow?.taskIds?.length ?? 0;
32319
+ const columnMembership = cycleTasksAllRaw.filter((t) => t.cycle === currentCycle2).length;
32320
+ if (cycleRow && taskIdMembership !== columnMembership && taskIdMembership > 0) {
32321
+ buildResult.warnings.unshift(
32322
+ `\u26A0\uFE0F Cycle ${currentCycle2} membership disagrees: the cycle row lists ${taskIdMembership} task(s) but ${columnMembership} task(s) actually carry cycle=${currentCycle2}. Some cycle tasks may not show on \`board_view cycle=${currentCycle2}\` or the hub. Run \`board_reconcile\` to realign them.`
32323
+ );
32324
+ }
32325
+ } catch {
32326
+ }
32327
+ }
32168
32328
  tracker.mark("parallel-reads");
32169
32329
  const inProgressItems = buildResult.inProgress.map(
32170
32330
  (t) => `- **${t.id}:** ${t.title} (${t.priority} | ${t.complexity})`
@@ -32781,23 +32941,23 @@ ${formatRuntimeIdentity(getRuntimeIdentity(config2, serverVersion))}`;
32781
32941
  }
32782
32942
  }
32783
32943
  function enrichClaudeMd(projectRoot, cycleNumber, adapterType, collector, clientName) {
32784
- if (!shouldWriteClaudeMd(clientName)) return "";
32944
+ const targetFile = shouldWriteClaudeMd(clientName) ? "CLAUDE.md" : "AGENTS.md";
32785
32945
  if (adapterType === "proxy") {
32786
32946
  const additions2 = [];
32787
32947
  if (cycleNumber >= 6) additions2.push(CLAUDE_MD_TIER_1);
32788
32948
  if (cycleNumber >= 21) additions2.push(CLAUDE_MD_TIER_2);
32789
32949
  if (additions2.length === 0) return "";
32790
- collector.add({ path: "CLAUDE.md", content: additions2.join(""), mode: "append" });
32950
+ collector.add({ path: targetFile, content: additions2.join(""), mode: "append" });
32791
32951
  const tierNames2 = [];
32792
32952
  if (cycleNumber >= 6) tierNames2.push("Established (batch building, strategy reviews, AD lifecycle)");
32793
32953
  if (cycleNumber >= 21) tierNames2.push("Mature (idea pipeline, doc registry, advanced patterns)");
32794
32954
  return `
32795
32955
 
32796
- \u{1F4DD} **CLAUDE.md enriched** \u2014 added ${tierNames2.join(" + ")} guidance for cycle ${cycleNumber}+ projects.`;
32956
+ \u{1F4DD} **${targetFile} enriched** \u2014 added ${tierNames2.join(" + ")} guidance for cycle ${cycleNumber}+ projects.`;
32797
32957
  }
32798
- const claudeMdPath = join18(projectRoot, "CLAUDE.md");
32799
- if (!existsSync11(claudeMdPath)) return "";
32800
- const content = readFileSync13(claudeMdPath, "utf-8");
32958
+ const targetPath = join18(projectRoot, targetFile);
32959
+ if (!existsSync11(targetPath)) return "";
32960
+ const content = readFileSync13(targetPath, "utf-8");
32801
32961
  const additions = [];
32802
32962
  if (cycleNumber >= 6 && !content.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T1)) {
32803
32963
  additions.push(dedupeEnrichmentBlob(content, CLAUDE_MD_TIER_1));
@@ -32806,13 +32966,13 @@ function enrichClaudeMd(projectRoot, cycleNumber, adapterType, collector, client
32806
32966
  additions.push(dedupeEnrichmentBlob(content, CLAUDE_MD_TIER_2));
32807
32967
  }
32808
32968
  if (additions.length === 0) return "";
32809
- writeFileSync7(claudeMdPath, content + additions.join(""), "utf-8");
32969
+ writeFileSync7(targetPath, content + additions.join(""), "utf-8");
32810
32970
  const tierNames = [];
32811
32971
  if (additions.some((a) => a.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T1))) tierNames.push("Established (batch building, strategy reviews, AD lifecycle)");
32812
32972
  if (additions.some((a) => a.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T2))) tierNames.push("Mature (idea pipeline, doc registry, advanced patterns)");
32813
32973
  return `
32814
32974
 
32815
- \u{1F4DD} **CLAUDE.md enriched** \u2014 added ${tierNames.join(" + ")} guidance for cycle ${cycleNumber}+ projects.`;
32975
+ \u{1F4DD} **${targetFile} enriched** \u2014 added ${tierNames.join(" + ")} guidance for cycle ${cycleNumber}+ projects.`;
32816
32976
  }
32817
32977
 
32818
32978
  // src/tools/hierarchy.ts
@@ -35560,6 +35720,9 @@ var PAPI_TOOLS = [
35560
35720
  function getQualifiedPapiTools() {
35561
35721
  return qualifyTools(PAPI_TOOLS);
35562
35722
  }
35723
+ function hasPendingStdioPrepare() {
35724
+ return hasPendingPlanPrepare() || hasPendingStrategyReview() || hasPendingHandoffGenerate();
35725
+ }
35563
35726
  function getToolMetadata() {
35564
35727
  return PAPI_TOOLS.map((t) => ({ name: t.name, description: t.description }));
35565
35728
  }
@@ -35684,7 +35847,7 @@ function createServer(adapter2, config2, observation) {
35684
35847
  const runHandler = async () => {
35685
35848
  switch (name) {
35686
35849
  case "plan":
35687
- return handlePlan(scopedAdapter, scopedConfig, safeArgs);
35850
+ return handlePlan(scopedAdapter, scopedConfig, safeArgs, server2.getClientVersion()?.name);
35688
35851
  case "strategy_review":
35689
35852
  return handleStrategyReview(scopedAdapter, scopedConfig, safeArgs);
35690
35853
  case "strategy_change":
@@ -36454,11 +36617,36 @@ function startHttpTransport(opts) {
36454
36617
  }
36455
36618
  try {
36456
36619
  parsedBody = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
36457
- observation?.setTool(extractToolName(parsedBody) ?? extractRpcMethod(parsedBody));
36458
36620
  } catch {
36459
36621
  sendError(res, { status: 400, body: { error: "Invalid JSON body" } });
36460
36622
  return;
36461
36623
  }
36624
+ if (Array.isArray(parsedBody)) {
36625
+ logEvent({
36626
+ level: "warn",
36627
+ msg: "jsonrpc_batch_rejected",
36628
+ ip,
36629
+ bearer_prefix: bearerPrefix(bearer),
36630
+ status: 400
36631
+ });
36632
+ if (!res.headersSent) {
36633
+ res.writeHead(400, {
36634
+ "Content-Type": "application/json",
36635
+ "CDN-Cache-Control": "private, no-store",
36636
+ "Vercel-CDN-Cache-Control": "private, no-store"
36637
+ });
36638
+ res.end(JSON.stringify({
36639
+ jsonrpc: "2.0",
36640
+ id: null,
36641
+ error: {
36642
+ code: -32600,
36643
+ message: "JSON-RPC batch requests are not supported. Send each tool call as its own HTTP request."
36644
+ }
36645
+ }));
36646
+ }
36647
+ return;
36648
+ }
36649
+ observation?.setTool(extractToolName(parsedBody) ?? extractRpcMethod(parsedBody));
36462
36650
  }
36463
36651
  logEvent({
36464
36652
  level: "info",
@@ -36707,6 +36895,56 @@ function shouldCheckForUpdate(version, selfHostFlag) {
36707
36895
  return version !== "unknown" && !isSelfHostedDeployment(selfHostFlag);
36708
36896
  }
36709
36897
 
36898
+ // src/lib/stdio-idle-timeout.ts
36899
+ var DEFAULT_STDIO_IDLE_TIMEOUT_MS = 12e4;
36900
+ var DEFAULT_STDIO_PENDING_GRACE_MS = 15 * 6e4;
36901
+ function parseStdioIdleTimeoutMs(raw = process.env["PAPI_STDIO_IDLE_TIMEOUT_MS"]) {
36902
+ if (raw === void 0 || raw.trim() === "") return DEFAULT_STDIO_IDLE_TIMEOUT_MS;
36903
+ const timeoutMs = Number(raw);
36904
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 0) {
36905
+ throw new Error(
36906
+ `PAPI_STDIO_IDLE_TIMEOUT_MS must be a non-negative integer in milliseconds; received ${JSON.stringify(raw)}`
36907
+ );
36908
+ }
36909
+ return timeoutMs;
36910
+ }
36911
+ function startStdioIdleTimeout(input, timeoutMs, onTimeout, isIdle = () => true) {
36912
+ if (timeoutMs === 0) return () => void 0;
36913
+ let timer2;
36914
+ let stopped = false;
36915
+ let pendingSince;
36916
+ const schedule = () => {
36917
+ timer2 = setTimeout(() => {
36918
+ if (stopped) return;
36919
+ if (!isIdle()) {
36920
+ pendingSince ??= Date.now();
36921
+ if (Date.now() - pendingSince < DEFAULT_STDIO_PENDING_GRACE_MS) {
36922
+ schedule();
36923
+ return;
36924
+ }
36925
+ }
36926
+ stopped = true;
36927
+ input.off("data", refresh);
36928
+ onTimeout();
36929
+ }, timeoutMs);
36930
+ };
36931
+ const stop = () => {
36932
+ if (stopped) return;
36933
+ stopped = true;
36934
+ if (timer2 !== void 0) clearTimeout(timer2);
36935
+ input.off("data", refresh);
36936
+ };
36937
+ const refresh = () => {
36938
+ if (stopped) return;
36939
+ if (timer2 !== void 0) clearTimeout(timer2);
36940
+ pendingSince = void 0;
36941
+ schedule();
36942
+ };
36943
+ input.on("data", refresh);
36944
+ refresh();
36945
+ return stop;
36946
+ }
36947
+
36710
36948
  // src/index.ts
36711
36949
  init_dist();
36712
36950
  var __dirname = dirname7(fileURLToPath5(import.meta.url));
@@ -36808,7 +37046,7 @@ async function gracefulShutdown(signal) {
36808
37046
  } catch (err) {
36809
37047
  console.error(`[papi] Adapter close failed during ${signal}: ${err instanceof Error ? err.message : String(err)}`);
36810
37048
  }
36811
- process.exit(signal === "SIGTERM" || signal === "SIGINT" ? 0 : 1);
37049
+ process.exit(signal === "SIGTERM" || signal === "SIGINT" || signal === "idle-timeout" ? 0 : 1);
36812
37050
  }
36813
37051
  process.on("SIGTERM", () => {
36814
37052
  void gracefulShutdown("SIGTERM");
@@ -36922,13 +37160,23 @@ if (isHttpMode && httpPort !== void 0) {
36922
37160
  } catch {
36923
37161
  }
36924
37162
  const transport = new StdioServerTransport();
37163
+ let stopStdioIdleTimeout = () => void 0;
37164
+ transport.onclose = () => stopStdioIdleTimeout();
36925
37165
  await server.connect(transport);
37166
+ stopStdioIdleTimeout = startStdioIdleTimeout(
37167
+ process.stdin,
37168
+ parseStdioIdleTimeoutMs(),
37169
+ () => {
37170
+ void gracefulShutdown("idle-timeout");
37171
+ },
37172
+ () => !hasPendingStdioPrepare()
37173
+ );
36926
37174
  process.stderr.write(`${formatConnectionBanner({
36927
37175
  serverName: config.serverName,
36928
37176
  projectName: basename2(config.projectRoot),
36929
37177
  projectId: config.projectId ?? process.env.PAPI_PROJECT_ID,
36930
37178
  adapterType: config.adapterType,
36931
37179
  pkgVersion
36932
- })}
37180
+ }, getConnectionStatus())}
36933
37181
  `);
36934
37182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.108",
3
+ "version": "0.7.113",
4
4
  "description": "PAPI MCP server — AI-powered sprint planning, build execution, and strategy review for software projects",
5
5
  "license": "Elastic-2.0",
6
6
  "mcpName": "io.github.getpapi/papi",