@agent-plan/pi-adapter 0.2.12 → 0.2.15

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,iCAAiC,CAAC;AA8yBtF,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAkrF9D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,iCAAiC,CAAC;AA+yBtF,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAgyF9D"}
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { Type } from "typebox";
12
12
  import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation } from "@agent-plan/core";
13
- import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, normalizeSlug } from "@agent-plan/core/naming";
13
+ import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug } from "@agent-plan/core/naming";
14
14
  import { join, dirname } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -72,6 +72,7 @@ let startupResumePromptPending = false;
72
72
  let startupResumeSummaryPending = false;
73
73
  let plannerHeavyInitDone = false; // runs migrate/heal/refreshResume once per session, not every turn
74
74
  let startupResumeSummaryText = "";
75
+ let startupResumeSummaryTimer = null;
75
76
  let contextBlockCache = ""; // cached before_agent_start context; rebuilt only when plan changes
76
77
  let contextBlockDirty = true; // build on first turn; invalidated by write notify hook
77
78
  let editedThisTurn = false; // tracks edit/write activity for the task_complete reminder
@@ -861,18 +862,31 @@ export default function planPiExtension(pi) {
861
862
  return;
862
863
  if (event.message.role !== "assistant")
863
864
  return;
864
- const hasVisibleText = event.message.content.some((item) => item.type === "text" && Boolean(item.text?.trim()));
865
- if (!hasVisibleText)
865
+ const visibleText = event.message.content
866
+ .filter((item) => item.type === "text")
867
+ .map((item) => item.text ?? "")
868
+ .join("");
869
+ if (!visibleText.trim())
866
870
  return;
867
- startupResumeSummaryPending = false;
868
- const summaryText = startupResumeSummaryText.trim();
869
- startupResumeSummaryText = "";
870
- if (!summaryText)
871
+ // Dedupe: if the agent already printed the address, don't double-append.
872
+ if (visibleText.includes("🌐 Web UI:"))
871
873
  return;
874
+ // Append the web address to EVERY assistant text message during the recap
875
+ // turn (template: "{agent_summary}\n\n{web_address}"). We intentionally do
876
+ // NOT consume the flag here: the recap turn can span several assistant
877
+ // messages (tool calls + final summary), and we must guarantee the address
878
+ // lands on the final summary the user actually reads. The flag is cleared
879
+ // at the start of the next turn (before_agent_start) plus a safety timeout.
880
+ const webServer = server;
881
+ const localUrl = webServer?.localUrl ?? webServer?.url ?? "";
882
+ if (!localUrl)
883
+ return;
884
+ const lanUrl = webServer?.lanUrl ?? "";
885
+ const urlLine = `\n\n🌐 Web UI: ${localUrl}${lanUrl ? ` — LAN: ${lanUrl}` : ""}`;
872
886
  return {
873
887
  message: {
874
888
  ...event.message,
875
- content: [{ type: "text", text: summaryText }],
889
+ content: [...event.message.content, { type: "text", text: urlLine }],
876
890
  },
877
891
  };
878
892
  });
@@ -906,6 +920,12 @@ export default function planPiExtension(pi) {
906
920
  // git pull, build, test, ls, etc. always work.
907
921
  if (event.toolName !== "edit" && event.toolName !== "write")
908
922
  return;
923
+ // Planner-internal writes (.planner/HANDOFF.md, resume.json, generated plan
924
+ // files, etc.) are planner operations, NOT code edits. They never require a
925
+ // task in-progress — skip the guard so the handoff can always be written.
926
+ const targetPath = String(event.input?.path ?? event.input?.filePath ?? "");
927
+ if (targetPath && (targetPath.includes("/.planner/") || targetPath.includes("\\.planner\\") || targetPath.startsWith(".planner/")))
928
+ return;
909
929
  const st = loadStore(ctx);
910
930
  if (!(await st.exists().catch(() => false)))
911
931
  return;
@@ -1802,8 +1822,7 @@ export default function planPiExtension(pi) {
1802
1822
  ctx.ui.notify("Aborted", "warning");
1803
1823
  return;
1804
1824
  }
1805
- const shortName = title.trim().toLowerCase()
1806
- .replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30);
1825
+ const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
1807
1826
  const taskNum = phase.tasks.length + 1;
1808
1827
  const taskId = createTaskId();
1809
1828
  const now = nowISO();
@@ -1976,8 +1995,7 @@ export default function planPiExtension(pi) {
1976
1995
  return;
1977
1996
  }
1978
1997
  if (normalizedStatus === "in-progress" && existsSync(join(st.root, "HANDOFF.md"))) {
1979
- ctx.ui.notify("🚨 HYGIENE VIOLATION: A pending handoff file exists at .planner/HANDOFF.md. You MUST read and delete it before you can move a task to in-progress.", "error");
1980
- return;
1998
+ ctx.ui.notify("ā„¹ļø A handoff exists at .planner/HANDOFF.md — read it for context, then delete it with /planner handoff clear (or plan_delete_handoff) when no longer needed. Proceeding with the status change.", "info");
1981
1999
  }
1982
2000
  applyTaskLifecycleDates(task, normalizedStatus, now);
1983
2001
  }
@@ -2018,10 +2036,12 @@ export default function planPiExtension(pi) {
2018
2036
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2019
2037
  return;
2020
2038
  }
2021
- // Hygiene Gate: block starting work if a pending handoff exists.
2039
+ // Hygiene notice (non-blocking): surface an existing handoff but never
2040
+ // block task_start — the handoff is a captured-context artifact, not a
2041
+ // lock (a small modification after writing a handoff should start without
2042
+ // forcing deletion, which would lose context).
2022
2043
  if (existsSync(join(st.root, "HANDOFF.md"))) {
2023
- ctx.ui.notify("🚨 HYGIENE VIOLATION: A pending handoff file exists at .planner/HANDOFF.md. You MUST read and delete it before you can officially start a task.", "error");
2024
- return;
2044
+ ctx.ui.notify("ā„¹ļø A handoff exists at .planner/HANDOFF.md — read it for context, then delete it with /planner handoff clear (or plan_delete_handoff) when no longer needed. Proceeding with task start.", "info");
2025
2045
  }
2026
2046
  const now = nowISO();
2027
2047
  // Record status change in the incremental statusLog.
@@ -2263,6 +2283,16 @@ export default function planPiExtension(pi) {
2263
2283
  // at the END of that summary (not in a notify here) so it stays visible.
2264
2284
  startupResumePromptPending = true;
2265
2285
  startupResumeSummaryPending = true;
2286
+ // Force the recap turn to rebuild the context (slow path) so the resume
2287
+ // protocol is injected fresh. The protocol is appended per-turn and is
2288
+ // never baked into the cache, so later turns won't see it.
2289
+ contextBlockDirty = true;
2290
+ if (startupResumeSummaryTimer)
2291
+ clearTimeout(startupResumeSummaryTimer);
2292
+ startupResumeSummaryTimer = setTimeout(() => {
2293
+ startupResumeSummaryPending = false;
2294
+ startupResumeSummaryTimer = null;
2295
+ }, 60000);
2266
2296
  pi.sendMessage({
2267
2297
  customType: "planner-resume-trigger",
2268
2298
  content: "planner recap",
@@ -3153,8 +3183,7 @@ export default function planPiExtension(pi) {
3153
3183
  catch {
3154
3184
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3155
3185
  }
3156
- const rawShort = params.shortName ?? normalizeSlug(params.title).slice(0, 30);
3157
- const shortName = rawShort.trim() || `task-${Date.now().toString(36)}`; // never empty (fixes regex invalid_string)
3186
+ const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3158
3187
  const taskId = createTaskId();
3159
3188
  const now = nowISO();
3160
3189
  const initialStatus = params.status ?? "planned";
@@ -3384,6 +3413,90 @@ export default function planPiExtension(pi) {
3384
3413
  return { content: [{ type: "text", text: `āœ… Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
3385
3414
  },
3386
3415
  });
3416
+ // ── Web lifecycle tools (agent-callable; parity with @agent-plan/mcp planner-web) ──
3417
+ // These let the agent manage the web dashboard directly, without relying
3418
+ // on the /planner slash command (which is not intercepted when the planner
3419
+ // is disabled by default). Planner operations are NOT code edits.
3420
+ pi.registerTool({
3421
+ name: "planner-web",
3422
+ label: "Planner Web",
3423
+ description: "Manage the planner web dashboard (start/status/stop). The dashboard is LAN-bound (0.0.0.0) with a dynamic OS-assigned port by default. Planner operations are NOT code edits and need no active task. The web does NOT auto-start; call action=start explicitly.",
3424
+ parameters: Type.Object({
3425
+ action: Type.Union([Type.Literal("start"), Type.Literal("stop"), Type.Literal("status")], { description: "start | stop | status. Default: status" }),
3426
+ port: Type.Optional(Type.Number({ description: "Optional requested port for start. Omit/0 → OS assigns a free port." })),
3427
+ visibility: Type.Optional(Type.Union([Type.Literal("local"), Type.Literal("lan")], { description: "Bind scope for start. Default: lan (0.0.0.0, reachable from other devices)." })),
3428
+ }),
3429
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3430
+ const action = params.action ?? "status";
3431
+ if (action === "start") {
3432
+ const st = await requirePlan(ctx);
3433
+ if (!st)
3434
+ return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { running: false } };
3435
+ const visibility = params.visibility ?? "lan";
3436
+ if (server) {
3437
+ const srv = server;
3438
+ return { content: [{ type: "text", text: `Web UI already running. Local: ${srv.localUrl}${srv.lanUrl ? ` — LAN: ${srv.lanUrl}` : ""} (mode: ${srv.mode}, port: ${lastKnownWebPort ?? "?"})` }], details: { running: true, localUrl: srv.localUrl, lanUrl: srv.lanUrl, port: lastKnownWebPort, mode: srv.mode } };
3439
+ }
3440
+ await startServer(ctx, params.port && params.port > 0 ? params.port : undefined, visibility);
3441
+ const srv = server;
3442
+ if (!srv)
3443
+ return { content: [{ type: "text", text: "Failed to start web server." }], details: { running: false } };
3444
+ return { content: [{ type: "text", text: `Web UI started. Local: ${srv.localUrl}${srv.lanUrl ? ` — LAN: ${srv.lanUrl}` : ""} (mode: ${srv.mode}, port: ${lastKnownWebPort ?? "?"})` }], details: { running: true, localUrl: srv.localUrl, lanUrl: srv.lanUrl, port: lastKnownWebPort, mode: srv.mode } };
3445
+ }
3446
+ if (action === "stop") {
3447
+ if (!server)
3448
+ return { content: [{ type: "text", text: "Web UI not running." }], details: { running: false } };
3449
+ const stoppedUrl = server.localUrl;
3450
+ await stopServer();
3451
+ return { content: [{ type: "text", text: `Web UI stopped (was ${stoppedUrl}).` }], details: { running: false } };
3452
+ }
3453
+ // status
3454
+ const srv = server;
3455
+ if (!srv)
3456
+ return { content: [{ type: "text", text: "Web UI not running. Use planner-web with action=start to start the dashboard." }], details: { running: false } };
3457
+ return { content: [{ type: "text", text: `Web UI running. Local: ${srv.localUrl}${srv.lanUrl ? ` — LAN: ${srv.lanUrl}` : ""} (mode: ${srv.mode}, port: ${lastKnownWebPort ?? "?"})` }], details: { running: true, localUrl: srv.localUrl, lanUrl: srv.lanUrl, port: lastKnownWebPort, mode: srv.mode } };
3458
+ },
3459
+ });
3460
+ pi.registerTool({
3461
+ name: "planner-load",
3462
+ label: "Planner Load",
3463
+ description: "Enable the planner for this project and start the web dashboard (LAN), then return a resume recap (project status + handoff if present + Web UI address). This is the explicit way to enable the planner — it does NOT auto-start. Planner operations are NOT code edits.",
3464
+ parameters: Type.Object({}),
3465
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
3466
+ const st = await requirePlan(ctx);
3467
+ if (!st)
3468
+ return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
3469
+ plannerSessionEnabled = true;
3470
+ if (!server) {
3471
+ await startServer(ctx, undefined, "lan").catch(() => { });
3472
+ }
3473
+ let recap = "";
3474
+ try {
3475
+ recap = await buildStartupResumeSummary(st);
3476
+ }
3477
+ catch (e) {
3478
+ recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
3479
+ }
3480
+ const srv = server;
3481
+ const webLine = srv ? `\n🌐 Web UI: ${srv.localUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}${srv.lanUrl ? ` — LAN: ${srv.lanUrl}` : ""}` : "\n🌐 Web UI: not running";
3482
+ return { content: [{ type: "text", text: `${recap}${webLine}` }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
3483
+ },
3484
+ });
3485
+ pi.registerTool({
3486
+ name: "planner-stop",
3487
+ label: "Planner Stop",
3488
+ description: "Disable the planner for this project and stop the web dashboard. Alias: planner-disable. Planner operations are NOT code edits.",
3489
+ parameters: Type.Object({}),
3490
+ async execute(_id, _params, _signal, _onUpdate, _ctx) {
3491
+ plannerSessionEnabled = false;
3492
+ await stopServer().catch(() => { });
3493
+ try {
3494
+ capturedPi?.appendEntry("plan-web-state", { running: false });
3495
+ }
3496
+ catch { }
3497
+ return { content: [{ type: "text", text: "Planner disabled. Web UI shut down. Run /planner load or planner-load to re-enable." }], details: { disabled: true, webRunning: false } };
3498
+ },
3499
+ });
3387
3500
  // ── Context injection ─────────────────────────────────────────────
3388
3501
  pi.on("before_agent_start", async (event, ctx) => {
3389
3502
  if (!plannerSessionEnabled)
@@ -3402,6 +3515,18 @@ export default function planPiExtension(pi) {
3402
3515
  await st.refreshResume();
3403
3516
  plannerHeavyInitDone = true;
3404
3517
  }
3518
+ // Resume-flag lifecycle — MUST run every turn, including cache hits.
3519
+ // /planner load sets both flags and dirties the cache so the recap turn
3520
+ // rebuilds (slow path) and injects the resume protocol. We consume
3521
+ // startupResumePromptPending here (it marks the recap turn only), and we
3522
+ // stop appending the web UI address on every later turn. Doing this
3523
+ // BEFORE the cache fast-path is what prevents the URL from leaking onto
3524
+ // every assistant message after /planner load.
3525
+ const isRecapTurn = startupResumePromptPending;
3526
+ startupResumePromptPending = false;
3527
+ if (startupResumeSummaryPending && !isRecapTurn) {
3528
+ startupResumeSummaryPending = false;
3529
+ }
3405
3530
  // Fast path: on steady turns (no plan changes since last build) reuse
3406
3531
  // the cached context block and skip all the per-turn I/O + string build.
3407
3532
  // The cache is invalidated by the write-notify hook whenever the plan
@@ -3499,10 +3624,7 @@ export default function planPiExtension(pi) {
3499
3624
  const ambientSummary = ambient
3500
3625
  ? `node=${ambient.nodeVersion || "?"} pm=${ambient.packageManager || "?"} lockfile=${ambient.lockfile || "none"} scripts=${Object.entries(ambient.scripts).map(([k, v]) => `${k}="${v}"`).join(", ") || "none"}`
3501
3626
  : "(not scanned)";
3502
- if (startupResumeSummaryPending) {
3503
- startupResumeSummaryText = await buildStartupResumeSummary(st).catch(() => "");
3504
- }
3505
- const startupResumeProtocol = startupResumePromptPending ? [
3627
+ const startupResumeProtocol = isRecapTurn ? [
3506
3628
  "",
3507
3629
  "STARTUP RESUME PROTOCOL (first reply of the session — mandatory):",
3508
3630
  "- You are resuming a planner-backed project session.",
@@ -3517,7 +3639,7 @@ export default function planPiExtension(pi) {
3517
3639
  `- Mention the next suggested activity: ${nextActivity}`,
3518
3640
  "- THEN ask the user explicitly whether they want to resume that activity now.",
3519
3641
  "- Do NOT assume yes. Wait for the user's answer before continuing implementation work.",
3520
- "- If the user says yes and no task is currently in-progress, your NEXT action must be task_start before any bash/edit/write.",
3642
+ "- If the user says yes and no task is currently in-progress, your NEXT action must be task_start before any CODE edit/write (bash for git/build/test is fine; planner operations like plan_write_handoff are NOT code edits and never require a task).",
3521
3643
  ].filter(Boolean).join("\n") : "";
3522
3644
  // Build/refresh the context block (slow path: cache miss).
3523
3645
  const contextBlock = [
@@ -3531,7 +3653,7 @@ export default function planPiExtension(pi) {
3531
3653
  "MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
3532
3654
  "═══════════════════════════════════════════════════════════════",
3533
3655
  "1. HANDOFF HYGIENE: If .planner/HANDOFF.md exists, READ and DELETE it IMMEDIATELY using the handoff delete tool. Do NOT summarize first. Do NOT ask for confirmation. Do NOT defer cleanup. DELETE \u2192 THEN work.",
3534
- "2. TASK LIFECYCLE: BEFORE coding ANY file: call task_start. AFTER finishing work on ANY task: call task_complete. No exceptions. No 'I'll do it later'.",
3656
+ "2. TASK LIFECYCLE: BEFORE coding ANY file: call task_start. AFTER finishing work on ANY task: call task_complete. No exceptions. No 'I'll do it later'. NOTE: Planner operations (plan_write_handoff / /planner handoff, task_start, task_complete, task_update, status reads) are NOT code edits. They are ALWAYS allowed regardless of task state — you may write/refresh the handoff even when no task is in-progress (e.g., all tasks done, or capturing state mid-flight). Never refuse to write a handoff because 'no task is open'; that is incorrect.",
3535
3657
  "3. IMMEDIATE SYNC: Update task status AT THE EXACT MOMENT of transition. Start = task_start NOW. Done = task_complete NOW. Blocked = task_update with motivation NOW. Never batch status updates.",
3536
3658
  "4. BLOCKED MOTIVATION: Transitions to blocked/canceled/rejected/deferred/waiting/planned(from non-planned) MUST include a detailed 'motivation' parameter. Write it as if the next person has zero context.",
3537
3659
  "5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
@@ -3609,14 +3731,14 @@ export default function planPiExtension(pi) {
3609
3731
  handoff?.content ? "If planner state shows no task/phase in-progress, do NOT present the handoff target as the current focus; present it only as a candidate to validate." : "",
3610
3732
  handoff?.content ? "When the handoff has been fully consumed and work is safely resumed, delete `.planner/HANDOFF.md` using the dedicated handoff delete command/tool." : "",
3611
3733
  handoff?.content ? handoff.content : "",
3612
- startupResumeProtocol,
3613
3734
  "Plan tools available: project_set_language_preferences, plan_init, project_update, requirement_list, requirement_create, requirement_update, requirement_delete, plan_get, feature_list, feature_get, feature_create, feature_update, feature_delete, phase_list, phase_get, phase_create, phase_update, phase_delete, task_list, task_get, task_create, task_update, task_delete, task_start, task_complete, plan_render, plan_get_handoff, plan_write_handoff, plan_delete_handoff, plan_authorize_bypass, plan_clear_bypass",
3614
3735
  ].filter(Boolean).join("\n");
3615
3736
  contextBlockCache = contextBlock;
3616
3737
  contextBlockDirty = false;
3617
- startupResumePromptPending = false;
3738
+ // The resume protocol is per-turn only (the recap turn) — never bake it
3739
+ // into the cached context block, or it would persist on every later turn.
3618
3740
  return {
3619
- systemPrompt: `${event.systemPrompt}\n\n---\n${contextBlockCache}`,
3741
+ systemPrompt: `${event.systemPrompt}\n\n---\n${contextBlockCache}${startupResumeProtocol ? `\n\n${startupResumeProtocol}` : ""}`,
3620
3742
  };
3621
3743
  }
3622
3744
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/pi-adapter",
3
- "version": "0.2.12",
3
+ "version": "0.2.15",
4
4
  "private": false,
5
5
  "description": "Pi agent extension for Agent Plan — structured project planning in .planner/",
6
6
  "keywords": [
@@ -26,10 +26,10 @@
26
26
  ]
27
27
  },
28
28
  "dependencies": {
29
- "@agent-plan/core": "^0.2.0",
30
- "@agent-plan/server": "^0.2.0",
31
29
  "typebox": "^1.1.24",
32
- "zod": "^3.24.0"
30
+ "zod": "^3.24.0",
31
+ "@agent-plan/core": "0.2.11",
32
+ "@agent-plan/server": "0.2.11"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@earendil-works/pi-coding-agent": "^0.74.0",