@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.5

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 (89) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +3563 -3540
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/config/settings-schema.d.ts +18 -8
  5. package/dist/types/config/settings.d.ts +3 -2
  6. package/dist/types/discovery/helpers.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  8. package/dist/types/irc/bus.d.ts +4 -0
  9. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  10. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  11. package/dist/types/modes/components/index.d.ts +2 -1
  12. package/dist/types/modes/components/model-browser.d.ts +97 -0
  13. package/dist/types/modes/components/model-hub.d.ts +51 -0
  14. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  15. package/dist/types/modes/components/session-selector.d.ts +13 -0
  16. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  17. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +1 -0
  19. package/dist/types/modes/shared.d.ts +1 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/session/agent-session.d.ts +1 -1
  22. package/dist/types/session/session-context.d.ts +9 -0
  23. package/dist/types/task/executor.d.ts +26 -13
  24. package/dist/types/task/index.d.ts +12 -11
  25. package/dist/types/task/label.d.ts +4 -0
  26. package/dist/types/task/repair-args.d.ts +8 -8
  27. package/dist/types/task/types.d.ts +31 -56
  28. package/dist/types/tools/ask.d.ts +12 -0
  29. package/dist/types/tools/conflict-detect.d.ts +17 -1
  30. package/dist/types/tools/job.d.ts +16 -0
  31. package/package.json +12 -12
  32. package/src/async/job-manager.ts +9 -0
  33. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  34. package/src/config/settings-schema.ts +17 -6
  35. package/src/config/settings.ts +13 -4
  36. package/src/discovery/helpers.ts +3 -4
  37. package/src/extensibility/custom-tools/loader.ts +70 -37
  38. package/src/extensibility/extensions/types.ts +46 -0
  39. package/src/irc/bus.ts +61 -20
  40. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  41. package/src/modes/components/advisor-config.ts +30 -22
  42. package/src/modes/components/ask-dialog.ts +888 -0
  43. package/src/modes/components/index.ts +2 -1
  44. package/src/modes/components/model-browser.ts +688 -0
  45. package/src/modes/components/model-hub.ts +1655 -0
  46. package/src/modes/components/pause-screen.ts +208 -0
  47. package/src/modes/components/session-selector.ts +299 -42
  48. package/src/modes/components/tool-execution.ts +2 -0
  49. package/src/modes/controllers/event-controller.ts +8 -2
  50. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  51. package/src/modes/controllers/selector-controller.ts +143 -87
  52. package/src/modes/controllers/tan-command-controller.ts +1 -1
  53. package/src/modes/interactive-mode.ts +3 -0
  54. package/src/modes/shared.ts +1 -1
  55. package/src/modes/theme/theme.ts +3 -3
  56. package/src/modes/types.ts +2 -0
  57. package/src/modes/utils/ui-helpers.ts +31 -4
  58. package/src/prompts/agents/scout.md +0 -1
  59. package/src/prompts/agents/task.md +1 -1
  60. package/src/prompts/system/subagent-system-prompt.md +1 -5
  61. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  62. package/src/prompts/system/task-label.md +23 -0
  63. package/src/prompts/tools/job.md +1 -1
  64. package/src/prompts/tools/task-summary.md +3 -0
  65. package/src/prompts/tools/task.md +17 -18
  66. package/src/session/agent-session.ts +2 -1
  67. package/src/session/session-context.test.ts +73 -0
  68. package/src/session/session-context.ts +43 -26
  69. package/src/slash-commands/builtin-registry.ts +9 -0
  70. package/src/task/agents.ts +2 -0
  71. package/src/task/executor.ts +159 -46
  72. package/src/task/index.ts +377 -239
  73. package/src/task/label.ts +38 -0
  74. package/src/task/render.ts +74 -22
  75. package/src/task/repair-args.ts +20 -31
  76. package/src/task/spawn-policy.test.ts +4 -4
  77. package/src/task/types.ts +46 -66
  78. package/src/tools/ask.ts +233 -40
  79. package/src/tools/conflict-detect.ts +102 -5
  80. package/src/tools/index.ts +1 -0
  81. package/src/tools/irc.ts +20 -11
  82. package/src/tools/job.ts +158 -18
  83. package/src/tools/write.ts +70 -6
  84. package/src/vibe/runtime.ts +1 -1
  85. package/src/web/search/providers/browser-headers.ts +30 -13
  86. package/dist/types/modes/components/model-selector.d.ts +0 -37
  87. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  88. package/src/modes/components/model-selector.ts +0 -1291
  89. package/src/tools/bash-command-fixup.ts +0 -4
package/src/tools/irc.ts CHANGED
@@ -386,20 +386,29 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
386
386
  };
387
387
  }
388
388
  const timeoutMs = this.#resolveTimeoutMs(params);
389
- const waited = await IrcBus.global().wait(senderId, { from }, timeoutMs, signal);
390
- if (!waited) {
391
- const filterNote = from ? ` from ${from}` : "";
389
+ try {
390
+ const waited = await IrcBus.global().wait(senderId, { from }, timeoutMs, signal, {
391
+ liveness: { registry, senderId },
392
+ });
393
+ if (!waited) {
394
+ const filterNote = from ? ` from ${from}` : "";
395
+ return {
396
+ content: [{ type: "text", text: `No message${filterNote} within ${formatDuration(timeoutMs)}.` }],
397
+ details: { op: "wait", from: senderId, waited: null },
398
+ // A clean wait timeout carries no information once consumed.
399
+ useless: true,
400
+ };
401
+ }
392
402
  return {
393
- content: [{ type: "text", text: `No message${filterNote} within ${formatDuration(timeoutMs)}.` }],
394
- details: { op: "wait", from: senderId, waited: null },
395
- // A clean wait timeout carries no information once consumed.
396
- useless: true,
403
+ content: [{ type: "text", text: formatIncoming(waited) }],
404
+ details: { op: "wait", from: senderId, waited },
397
405
  };
406
+ } catch (error) {
407
+ if (signal?.aborted) {
408
+ throw error;
409
+ }
410
+ return errorResult(error instanceof Error ? error.message : String(error), { op: "wait", from: senderId });
398
411
  }
399
- return {
400
- content: [{ type: "text", text: formatIncoming(waited) }],
401
- details: { op: "wait", from: senderId, waited },
402
- };
403
412
  }
404
413
 
405
414
  #executeInbox(registry: AgentRegistry, senderId: string, params: IrcParams): AgentToolResult<IrcDetails> {
package/src/tools/job.ts CHANGED
@@ -61,9 +61,26 @@ interface CancelOutcome {
61
61
  message: string;
62
62
  }
63
63
 
64
+ /**
65
+ * A live subagent from the AgentRegistry that has no backing job in the
66
+ * AsyncJobManager — e.g. an idle agent woken (or a parked agent revived) via
67
+ * `irc`, or a spawn owned by another agent. Surfaced by `list` and empty-poll
68
+ * snapshots so the job tool's picture matches the UI's running-agent count.
69
+ */
70
+ interface AgentActivitySnapshot {
71
+ id: string;
72
+ parentId?: string;
73
+ /** Latest activity gist recorded by the registry (display-only). */
74
+ activity?: string;
75
+ /** Time since the agent was registered. */
76
+ ageMs: number;
77
+ }
78
+
64
79
  export interface JobToolDetails {
65
80
  jobs: JobSnapshot[];
66
81
  cancelled?: { id: string; status: CancelStatus }[];
82
+ /** Running subagents not represented by a job row in this result. */
83
+ agents?: AgentActivitySnapshot[];
67
84
  }
68
85
 
69
86
  /**
@@ -118,7 +135,9 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
118
135
  if (params.cancel?.length || params.poll?.length) {
119
136
  throw new ToolError("`list` cannot be combined with `poll` or `cancel`.");
120
137
  }
121
- return this.#buildResult(manager, manager.getAllJobs(ownerFilter), []);
138
+ const jobs = manager.getAllJobs(ownerFilter);
139
+ const agents = this.#runningAgentsOutsideJobs();
140
+ return this.#buildResult(manager, jobs, [], agents);
122
141
  }
123
142
 
124
143
  const cancelIds = params.cancel ?? [];
@@ -166,15 +185,37 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
166
185
  const cancelledJobs = this.#visibleJobs(manager, cancelIds, ownerId);
167
186
  return this.#buildResult(manager, cancelledJobs, cancelOutcomes);
168
187
  }
169
- const message = requestedPollIds?.length
170
- ? `No matching jobs found for IDs: ${requestedPollIds.join(", ")}`
171
- : "No running background jobs to wait for.";
188
+ // Zero pollable jobs is not necessarily "nothing running": agents
189
+ // woken via irc or owned by another agent run with no job entry.
190
+ // Report them so the snapshot matches the UI's running-agent count
191
+ // (task job ids are agent ids, so a stale poll id often names one).
192
+ const agents = this.#runningAgentsOutsideJobs();
193
+ const lines: string[] = [];
194
+ if (requestedPollIds?.length) {
195
+ lines.push(`No matching jobs found for IDs: ${requestedPollIds.join(", ")}`);
196
+ const registry = this.session.agentRegistry;
197
+ for (const id of requestedPollIds) {
198
+ const ref = registry?.get(id);
199
+ if (!ref) continue;
200
+ lines.push(
201
+ ref.status === "running"
202
+ ? `- \`${id}\` is a running agent with no job entry — coordinate via \`irc\`; transcript at history://${id}`
203
+ : `- \`${id}\` is a ${ref.status} agent (its job is gone) — transcript at history://${id}`,
204
+ );
205
+ }
206
+ } else {
207
+ lines.push("No running background jobs to wait for.");
208
+ }
209
+ if (agents.length > 0) {
210
+ lines.push("", ...this.#describeAgents(agents));
211
+ }
172
212
  return {
173
- content: [{ type: "text", text: message }],
174
- details: { jobs: [] },
213
+ content: [{ type: "text", text: lines.join("\n") }],
214
+ details: { jobs: [], ...(agents.length ? { agents } : {}) },
175
215
  // Nothing found / nothing to wait for is noise once consumed —
176
- // the follow-up call has already corrected course.
177
- useless: true,
216
+ // the follow-up call has already corrected course. Running agents
217
+ // are real state the model may act on, so keep those results.
218
+ ...(agents.length === 0 ? { useless: true } : {}),
178
219
  };
179
220
  }
180
221
 
@@ -266,6 +307,57 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
266
307
  return out;
267
308
  }
268
309
 
310
+ /**
311
+ * Running subagents from the registry that are not covered by one of the
312
+ * caller's running jobs. Agents woken via `irc` (idle wake / park revival)
313
+ * and spawns owned by another agent run with no AsyncJobManager entry, yet
314
+ * the UI's agent badge counts them — a snapshot must account for that
315
+ * activity instead of implying the system is quiet. Existence is already
316
+ * public via the `irc` roster, so listing ids here leaks nothing new; job
317
+ * *control* stays owner-scoped.
318
+ */
319
+ #runningAgentsOutsideJobs(): AgentActivitySnapshot[] {
320
+ const registry = this.session.agentRegistry;
321
+ if (!registry) return [];
322
+ const selfId = this.session.getAgentId?.() ?? undefined;
323
+ // Cover = the caller's RUNNING jobs only. A settled job still sitting in
324
+ // delivery retention must not hide its agent if that agent was re-woken
325
+ // (e.g. via irc) and is running again without a job.
326
+ const covered = new Set<string>();
327
+ const manager = this.session.asyncJobManager;
328
+ if (manager) {
329
+ for (const job of manager.getRunningJobs(selfId ? { ownerId: selfId } : undefined)) {
330
+ covered.add(job.id);
331
+ if (job.agentId) covered.add(job.agentId);
332
+ }
333
+ }
334
+ const now = Date.now();
335
+ const out: AgentActivitySnapshot[] = [];
336
+ for (const ref of registry.list()) {
337
+ if (ref.kind !== "sub" || ref.status !== "running") continue;
338
+ if (ref.id === selfId || covered.has(ref.id)) continue;
339
+ out.push({
340
+ id: ref.id,
341
+ ...(ref.parentId ? { parentId: ref.parentId } : {}),
342
+ ...(ref.activity ? { activity: ref.activity } : {}),
343
+ ageMs: Math.max(0, now - ref.createdAt),
344
+ });
345
+ }
346
+ return out;
347
+ }
348
+
349
+ /** Model-facing lines for the running-agents section shared by `list` and empty-poll results. */
350
+ #describeAgents(agents: AgentActivitySnapshot[]): string[] {
351
+ const lines = [`## Running Agents (${agents.length}) — not job-backed\n`];
352
+ for (const agent of agents) {
353
+ const parent = agent.parentId ? ` (spawned by \`${agent.parentId}\`)` : "";
354
+ const activity = agent.activity ? ` — ${agent.activity}` : "";
355
+ lines.push(`- \`${agent.id}\`${parent} — up ${formatDuration(agent.ageMs)}${activity}`);
356
+ }
357
+ lines.push("", "These agents have no job entry; coordinate via `irc`, transcripts at `history://<id>`.");
358
+ return lines;
359
+ }
360
+
269
361
  #snapshotJobs(
270
362
  jobs: {
271
363
  id: string;
@@ -305,6 +397,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
305
397
  errorText?: string;
306
398
  }[],
307
399
  cancelOutcomes: CancelOutcome[],
400
+ agents: AgentActivitySnapshot[] = [],
308
401
  ): AgentToolResult<JobToolDetails> {
309
402
  // Deduplicate by id (cancelled jobs may also appear in the watched set).
310
403
  const seen = new Set<string>();
@@ -350,9 +443,21 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
350
443
  }
351
444
  }
352
445
 
446
+ if (agents.length > 0) {
447
+ if (lines.length > 0) lines.push("");
448
+ lines.push(...this.#describeAgents(agents));
449
+ }
450
+
451
+ // A tool result must never be empty text — the model cannot tell "no
452
+ // jobs" from a malfunction (reported exactly that way in QA).
453
+ if (lines.length === 0) {
454
+ lines.push("No background jobs.");
455
+ }
456
+
353
457
  const details: JobToolDetails = {
354
458
  jobs: jobResults,
355
459
  ...(cancelOutcomes.length ? { cancelled: cancelOutcomes.map(({ id, status }) => ({ id, status })) } : {}),
460
+ ...(agents.length ? { agents } : {}),
356
461
  };
357
462
  return {
358
463
  content: [{ type: "text", text: lines.join("\n").trimEnd() }],
@@ -463,8 +568,9 @@ export const jobToolRenderer = {
463
568
  args?: JobRenderArgs,
464
569
  ): Component {
465
570
  let jobs = result.details?.jobs ?? [];
571
+ const agents = result.details?.agents ?? [];
466
572
 
467
- if (jobs.length === 0) {
573
+ if (jobs.length === 0 && agents.length === 0) {
468
574
  const fallback = result.content?.find(c => c.type === "text")?.text || "No jobs to process";
469
575
  const header = renderStatusLine({ icon: "warning", title: describeTarget(args) || "Job" }, uiTheme);
470
576
  return new Text([header, formatEmptyMessage(fallback, uiTheme)].join("\n"), 0, 0);
@@ -474,7 +580,10 @@ export const jobToolRenderer = {
474
580
  ? !args.list && (!args.cancel || args.cancel.length === 0 || args.poll !== undefined)
475
581
  : true;
476
582
 
477
- if (!options.isPartial && isPollCall) {
583
+ // Agent-carrying results (list / empty-poll roster) are real snapshots,
584
+ // not displaceable waiting frames — only agentless polls collapse their
585
+ // still-running rows once sealed.
586
+ if (!options.isPartial && isPollCall && agents.length === 0) {
478
587
  jobs = jobs.filter(job => job.status !== "running");
479
588
  if (jobs.length === 0) {
480
589
  return new Text("", 0, 0);
@@ -490,20 +599,26 @@ export const jobToolRenderer = {
490
599
  if (counts.completed > 0) meta.push(uiTheme.fg("success", `${counts.completed} done`));
491
600
  if (counts.failed > 0) meta.push(uiTheme.fg("error", `${counts.failed} failed`));
492
601
  if (counts.cancelled > 0) meta.push(uiTheme.fg("warning", `${counts.cancelled} cancelled`));
602
+ if (agents.length > 0 && jobs.length > 0) {
603
+ meta.push(uiTheme.fg("accent", `${agents.length} agent${agents.length === 1 ? "" : "s"}`));
604
+ }
493
605
 
494
- const headerIcon: ToolUIStatus = counts.failed > 0 ? "warning" : counts.running > 0 ? "info" : "success";
606
+ const headerIcon: ToolUIStatus =
607
+ counts.failed > 0 ? "warning" : counts.running > 0 || agents.length > 0 ? "info" : "success";
495
608
  const jobsNoun = jobs.length === 1 ? "job" : "jobs";
496
609
  const description =
497
- counts.running > 0
498
- ? counts.running === jobs.length
499
- ? `waiting on ${jobs.length} ${jobsNoun}`
500
- : `waiting on ${counts.running} of ${jobs.length} ${jobsNoun}`
501
- : `${jobs.length} ${jobsNoun} settled`;
610
+ jobs.length === 0
611
+ ? `${agents.length} running agent${agents.length === 1 ? "" : "s"} — no jobs`
612
+ : counts.running > 0
613
+ ? counts.running === jobs.length
614
+ ? `waiting on ${jobs.length} ${jobsNoun}`
615
+ : `waiting on ${counts.running} of ${jobs.length} ${jobsNoun}`
616
+ : `${jobs.length} ${jobsNoun} settled`;
502
617
 
503
618
  const header = renderStatusLine(
504
619
  {
505
620
  icon: headerIcon,
506
- spinnerFrame: counts.running > 0 ? options.spinnerFrame : undefined,
621
+ spinnerFrame: counts.running > 0 || agents.length > 0 ? options.spinnerFrame : undefined,
507
622
  title: description,
508
623
  meta,
509
624
  },
@@ -598,7 +713,32 @@ export const jobToolRenderer = {
598
713
  uiTheme,
599
714
  );
600
715
 
601
- const all = [header, ...itemLines].map(l => truncateToWidth(l, width, Ellipsis.Unicode));
716
+ // Agents run outside job control; render them as their own tree so
717
+ // they never skew the job counts or the "waiting on N jobs" title.
718
+ const agentLines =
719
+ agents.length === 0
720
+ ? []
721
+ : renderTreeList<AgentActivitySnapshot>(
722
+ {
723
+ items: agents,
724
+ expanded,
725
+ maxCollapsed: COLLAPSED_LIST_LIMIT,
726
+ itemType: "agent",
727
+ renderItem: agent => {
728
+ const icon = formatStatusIcon("running", uiTheme, options.spinnerFrame);
729
+ const badge = formatBadge("agent", "accent", uiTheme);
730
+ const gist = agent.activity
731
+ ? ` ${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(agent.activity), LABEL_MAX_WIDTH, Ellipsis.Unicode))}`
732
+ : "";
733
+ const parent = agent.parentId ? uiTheme.fg("dim", ` ← ${agent.parentId}`) : "";
734
+ const age = uiTheme.fg("dim", formatDuration(agent.ageMs));
735
+ return [`${icon} ${uiTheme.fg("muted", agent.id)} ${badge}${gist} ${age}${parent}`];
736
+ },
737
+ },
738
+ uiTheme,
739
+ );
740
+
741
+ const all = [header, ...itemLines, ...agentLines].map(l => truncateToWidth(l, width, Ellipsis.Unicode));
602
742
  cached = { key, lines: all };
603
743
  return all;
604
744
  },
@@ -82,6 +82,30 @@ import { toolResult } from "./tool-result";
82
82
  const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
83
83
  const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
84
84
 
85
+ const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
86
+
87
+ /**
88
+ * Parse `conflict://*` per-id directive content: every non-empty line must be
89
+ * `<id>: @side` (also accepted: `#<id> = @side`). Returns `null` when the
90
+ * content is not directive-shaped (→ uniform bulk mode); throws on duplicate
91
+ * ids so a typo never silently drops a resolution.
92
+ */
93
+ function parseBulkDirectives(content: string): Map<number, string> | null {
94
+ const map = new Map<number, string>();
95
+ for (const raw of content.split("\n")) {
96
+ const line = raw.trim();
97
+ if (line.length === 0) continue;
98
+ const match = line.match(BULK_DIRECTIVE_RE);
99
+ if (!match) return null;
100
+ const id = Number.parseInt(match[1], 10);
101
+ if (map.has(id)) {
102
+ throw new ToolError(`Bulk directive lists conflict #${id} twice — each id may appear once.`);
103
+ }
104
+ map.set(id, match[2]);
105
+ }
106
+ return map.size > 0 ? map : null;
107
+ }
108
+
85
109
  const writeSchema = type({
86
110
  path: type("string").describe("file path"),
87
111
  content: type("string").describe("file content"),
@@ -591,7 +615,8 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
591
615
 
592
616
  const expanded = expandContentTokens(replacementContent, entry);
593
617
  const originalText = await Bun.file(absolutePath).text();
594
- const newContent = spliceConflict(originalText, entry, expanded);
618
+ const splice = spliceConflict(originalText, entry, expanded);
619
+ const newContent = splice.text;
595
620
 
596
621
  await writethroughNoop(absolutePath, newContent, signal);
597
622
  invalidateFsScanAfterWrite(absolutePath);
@@ -626,6 +651,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
626
651
  if (stripped) {
627
652
  resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
628
653
  }
654
+ const echoTrimmed = splice.trimmedLeading + splice.trimmedTrailing;
655
+ if (echoTrimmed > 0) {
656
+ resultText += `\nNote: dropped ${echoTrimmed} content line(s) that duplicated the code adjacent to the conflict region — writes replace only the marker block; surrounding lines stay in place.`;
657
+ }
629
658
 
630
659
  return {
631
660
  content: [{ type: "text", text: resultText }],
@@ -671,6 +700,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
671
700
  replacementContent: string,
672
701
  stripped: boolean,
673
702
  signal: AbortSignal | undefined,
703
+ rawContent: string = replacementContent,
674
704
  ): Promise<AgentToolResult<WriteToolDetails>> {
675
705
  const history = getConflictHistory(this.session);
676
706
  const allEntries = history.entries();
@@ -680,8 +710,28 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
680
710
  );
681
711
  }
682
712
 
713
+ // Per-id directive mode: content made solely of `<id>: @side` lines
714
+ // resolves each listed conflict with that side in one call. Ideal for
715
+ // merge-hell files where dozens of pick-one blocks each need their own
716
+ // winner — one call instead of one write per conflict. Parsed from the
717
+ // PRE-strip content: hashline prefix stripping would otherwise eat the
718
+ // `<id>: ` heads as echoed line numbers.
719
+ const directives = parseBulkDirectives(rawContent) ?? parseBulkDirectives(replacementContent);
720
+ if (directives) {
721
+ const known = new Set(allEntries.map(entry => entry.id));
722
+ const unknown = [...directives.keys()].filter(id => !known.has(id));
723
+ if (unknown.length > 0) {
724
+ throw new ToolError(
725
+ `Bulk directive references unknown conflict id(s) ${unknown.map(id => `#${id}`).join(", ")}. Currently registered: ${allEntries.map(e => `#${e.id}`).join(", ")}.`,
726
+ );
727
+ }
728
+ }
729
+ const selectedEntries = directives ? allEntries.filter(entry => directives.has(entry.id)) : allEntries;
730
+ const contentFor = (entry: ConflictEntry): string =>
731
+ directives ? (directives.get(entry.id) as string) : replacementContent;
732
+
683
733
  const byFile = new Map<string, ConflictEntry[]>();
684
- for (const entry of allEntries) {
734
+ for (const entry of selectedEntries) {
685
735
  const bucket = byFile.get(entry.absolutePath) ?? [];
686
736
  bucket.push(entry);
687
737
  byFile.set(entry.absolutePath, bucket);
@@ -690,6 +740,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
690
740
  const succeededFiles: { displayPath: string; count: number; header?: string }[] = [];
691
741
  const failedFiles: { displayPath: string; count: number; error: string }[] = [];
692
742
  let totalResolvedIds = 0;
743
+ let totalEchoTrimmed = 0;
693
744
 
694
745
  for (const [absolutePath, fileEntries] of byFile) {
695
746
  const sample = fileEntries[0]!;
@@ -720,8 +771,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
720
771
  }
721
772
  for (const entry of fileEntries) {
722
773
  try {
723
- const expanded = expandContentTokens(replacementContent, entry);
724
- text = spliceConflict(text, entry, expanded);
774
+ const expanded = expandContentTokens(contentFor(entry), entry);
775
+ const splice = spliceConflict(text, entry, expanded);
776
+ text = splice.text;
777
+ totalEchoTrimmed += splice.trimmedLeading + splice.trimmedTrailing;
725
778
  resolvedEntries.push(entry);
726
779
  } catch (error) {
727
780
  // A locate-miss for a region an earlier entry already spliced
@@ -766,6 +819,17 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
766
819
  summaryLines.push(` ${file.displayPath}: ${file.count} ${conflictWord(file.count)}`);
767
820
  }
768
821
  }
822
+ if (directives && selectedEntries.length < allEntries.length) {
823
+ const remaining = allEntries.filter(entry => !directives.has(entry.id)).map(entry => `#${entry.id}`);
824
+ summaryLines.push(
825
+ `Directive mode: ${remaining.length} unlisted ${conflictWord(remaining.length)} still registered (${remaining.join(", ")}).`,
826
+ );
827
+ }
828
+ if (totalEchoTrimmed > 0) {
829
+ summaryLines.push(
830
+ `Note: dropped ${totalEchoTrimmed} content line(s) that duplicated code adjacent to conflict regions — writes replace only the marker block; surrounding lines stay in place.`,
831
+ );
832
+ }
769
833
  if (failedFiles.length > 0) {
770
834
  summaryLines.push(
771
835
  `Failed to resolve ${failedFiles.length} ${fileWord(failedFiles.length)} — registered entries left intact for retry:`,
@@ -781,7 +845,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
781
845
  summaryLines.push("Snapshots:");
782
846
  for (const header of headerLines) summaryLines.push(` ${header}`);
783
847
  }
784
- if (stripped) {
848
+ if (stripped && !directives) {
785
849
  summaryLines.push("Note: auto-stripped hashline display prefixes from content before writing.");
786
850
  }
787
851
  const resultText = summaryLines.join("\n");
@@ -849,7 +913,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
849
913
  emitWriteProgress(onUpdate, cleanContent, path);
850
914
  const result =
851
915
  conflictUri.id === "*"
852
- ? await this.#resolveAllConflicts(cleanContent, stripped, signal)
916
+ ? await this.#resolveAllConflicts(cleanContent, stripped, signal, content)
853
917
  : await this.#resolveSingleConflictById(conflictUri.id, cleanContent, stripped, signal);
854
918
  if (conflictUri.recoveredPrefix !== undefined) {
855
919
  appendNoteToResult(
@@ -603,7 +603,7 @@ export class VibeSessionRegistry {
603
603
  );
604
604
  }
605
605
  },
606
- { id: `${record.id}-t${turnIndex}`, ownerId: record.ownerId },
606
+ { id: `${record.id}-t${turnIndex}`, agentId: record.id, ownerId: record.ownerId },
607
607
  );
608
608
  turn.jobId = jobId;
609
609
  record.turn = turn;
@@ -1,22 +1,35 @@
1
1
  import { HeaderGenerator } from "header-generator";
2
2
 
3
- // Instantiate the singleton header generator.
4
- // This matches modern browsers from real-world query statistics
5
- // and randomizes between Chrome, Firefox, Safari, and Edge.
6
- const generator = new HeaderGenerator({
7
- browserListQuery: "last 3 versions",
8
- devices: ["desktop"],
9
- operatingSystems: ["windows", "macos", "linux"],
10
- locales: ["en-US", "en"],
11
- httpVersion: "2",
12
- strict: false,
13
- });
3
+ // Lazily instantiate the singleton header generator. Bun single-file binaries do not
4
+ // bundle header-generator's fs-loaded data_files, so construction may throw when the
5
+ // original build-time node_modules path is absent.
6
+ let generator: HeaderGenerator | undefined;
7
+ let generatorUnavailable = false;
8
+
9
+ function getHeaderGenerator(): HeaderGenerator | undefined {
10
+ if (generatorUnavailable) return undefined;
11
+ try {
12
+ generator ??= new HeaderGenerator({
13
+ browserListQuery: "last 3 versions",
14
+ devices: ["desktop"],
15
+ operatingSystems: ["windows", "macos", "linux"],
16
+ locales: ["en-US", "en"],
17
+ httpVersion: "2",
18
+ strict: false,
19
+ });
20
+ return generator;
21
+ } catch {
22
+ generatorUnavailable = true;
23
+ return undefined;
24
+ }
25
+ }
14
26
 
15
27
  // A fallback desktop Mac Chrome navigation fingerprint matching
16
28
  // the previous static default setup for deterministic or non-randomized calls.
17
29
  const CHROME_FALLBACK_HEADERS: Record<string, string> = {
18
30
  Accept:
19
31
  "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
32
+ "Accept-Encoding": "gzip, deflate, br, zstd",
20
33
  "Accept-Language": "en-US,en;q=0.9",
21
34
  "Cache-Control": "max-age=0",
22
35
  Priority: "u=0, i",
@@ -81,10 +94,14 @@ export function buildBrowserNavigationHeaders(options?: { randomized?: boolean }
81
94
  return { ...CHROME_FALLBACK_HEADERS };
82
95
  }
83
96
 
97
+ const generator = getHeaderGenerator();
98
+ if (!generator) {
99
+ return { ...CHROME_FALLBACK_HEADERS };
100
+ }
101
+
84
102
  try {
85
103
  // Generate realistic, consistent headers with the Bayesian generator
86
- const generated = generator.getHeaders();
87
- return canonicalizeHeaderNames(generated);
104
+ return canonicalizeHeaderNames(generator.getHeaders());
88
105
  } catch {
89
106
  // Gracefully recover to the robust default profile on unexpected generator errors
90
107
  return { ...CHROME_FALLBACK_HEADERS };
@@ -1,37 +0,0 @@
1
- import type { Model } from "@oh-my-pi/pi-ai";
2
- import { Container, Input, type SgrMouseEvent, type TUI } from "@oh-my-pi/pi-tui";
3
- import type { ModelRegistry } from "../../config/model-registry.js";
4
- import type { Settings } from "../../config/settings.js";
5
- import { type ConfiguredThinkingLevel } from "../../thinking.js";
6
- interface ScopedModelItem {
7
- model: Model;
8
- thinkingLevel?: string;
9
- }
10
- type ModelSelectorAction = "modelRole" | "retryFallback";
11
- type RoleSelectCallback = (model: Model, role: string | null, thinkingLevel?: ConfiguredThinkingLevel, selector?: string, action?: ModelSelectorAction) => void;
12
- /**
13
- * Component that renders a model selector with provider tabs and context menu.
14
- * - Tab/Arrow Left/Right: Switch between provider tabs
15
- * - Arrow Up/Down: Navigate model list
16
- * - Enter: Open context menu to select action
17
- * - Escape: Close menu or selector
18
- */
19
- export declare class ModelSelectorComponent extends Container {
20
- #private;
21
- constructor(tui: TUI, _currentModel: Model | undefined, settings: Settings, modelRegistry: ModelRegistry, scopedModels: ReadonlyArray<ScopedModelItem>, onSelect: RoleSelectCallback, onCancel: () => void, options?: {
22
- temporaryOnly?: boolean;
23
- directSelect?: boolean;
24
- pickerHint?: string;
25
- initialSearchInput?: string;
26
- currentContextTokens?: number;
27
- });
28
- /**
29
- * Concatenate children like Container.render, recording where the model list
30
- * lands so routed mouse events can be hit-tested against it.
31
- */
32
- render(width: number): readonly string[];
33
- routeMouse(event: SgrMouseEvent, line: number, _col: number): void;
34
- handleInput(keyData: string): void;
35
- getSearchInput(): Input;
36
- }
37
- export {};
@@ -1,3 +0,0 @@
1
- /** Compatibility shim for extensions importing the historical bash fixup helper. */
2
- export type { BashFixupResult } from "@oh-my-pi/pi-natives";
3
- export { applyBashFixups } from "@oh-my-pi/pi-natives";