@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.16

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 (85) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli.js +3725 -4029
  3. package/dist/types/cli/args.d.ts +2 -5
  4. package/dist/types/cli/flag-tables.d.ts +2 -2
  5. package/dist/types/cli/session-picker.d.ts +4 -2
  6. package/dist/types/commands/launch.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +12 -1
  8. package/dist/types/eval/agent-bridge.d.ts +19 -0
  9. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  10. package/dist/types/eval/js/shared/types.d.ts +1 -1
  11. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  12. package/dist/types/eval/py/executor.d.ts +1 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  14. package/dist/types/main.d.ts +2 -0
  15. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  16. package/dist/types/modes/components/session-selector.d.ts +25 -0
  17. package/dist/types/task/isolation-runner.d.ts +128 -0
  18. package/dist/types/task/worktree.d.ts +14 -1
  19. package/dist/types/thinking.d.ts +15 -0
  20. package/dist/types/tools/eval-render.d.ts +3 -0
  21. package/dist/types/tools/eval.d.ts +11 -17
  22. package/dist/types/tools/todo.d.ts +26 -28
  23. package/dist/types/tui/output-block.d.ts +8 -0
  24. package/dist/types/utils/image-resize.d.ts +2 -0
  25. package/dist/types/web/search/providers/exa.d.ts +2 -0
  26. package/package.json +12 -12
  27. package/scripts/build-binary.ts +18 -4
  28. package/src/cli/args.ts +4 -5
  29. package/src/cli/flag-tables.ts +3 -3
  30. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  31. package/src/cli/gallery-fixtures/shell.ts +15 -23
  32. package/src/cli/session-picker.ts +17 -3
  33. package/src/commands/launch.ts +3 -3
  34. package/src/config/settings-schema.ts +13 -1
  35. package/src/edit/renderer.ts +34 -12
  36. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  37. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  38. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  39. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  40. package/src/eval/agent-bridge.ts +301 -71
  41. package/src/eval/jl/prelude.jl +32 -227
  42. package/src/eval/jl/runner.jl +38 -12
  43. package/src/eval/js/shared/helpers.ts +1 -114
  44. package/src/eval/js/shared/prelude.txt +13 -27
  45. package/src/eval/js/shared/runtime.ts +0 -6
  46. package/src/eval/js/shared/types.ts +1 -1
  47. package/src/eval/js/worker-protocol.ts +1 -1
  48. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  49. package/src/eval/py/executor.ts +1 -1
  50. package/src/eval/py/prelude.py +47 -105
  51. package/src/eval/py/runner.py +0 -6
  52. package/src/eval/rb/prelude.rb +21 -189
  53. package/src/eval/rb/runner.rb +116 -9
  54. package/src/export/html/tool-views.generated.js +29 -29
  55. package/src/internal-urls/docs-index.generated.txt +1 -1
  56. package/src/internal-urls/local-protocol.ts +100 -53
  57. package/src/main.ts +15 -4
  58. package/src/modes/acp/acp-event-mapper.ts +7 -2
  59. package/src/modes/components/plugin-settings.ts +7 -1
  60. package/src/modes/components/session-selector.ts +143 -29
  61. package/src/modes/controllers/command-controller.ts +5 -0
  62. package/src/modes/rpc/rpc-mode.ts +6 -0
  63. package/src/modes/utils/copy-targets.ts +7 -2
  64. package/src/prompts/system/system-prompt.md +3 -3
  65. package/src/prompts/system/workflow-notice.md +3 -3
  66. package/src/prompts/tools/bash.md +16 -0
  67. package/src/prompts/tools/eval.md +19 -19
  68. package/src/prompts/tools/todo.md +1 -1
  69. package/src/session/agent-session.ts +231 -50
  70. package/src/task/index.ts +61 -207
  71. package/src/task/isolation-runner.ts +354 -0
  72. package/src/task/worktree.ts +46 -9
  73. package/src/thinking.ts +20 -0
  74. package/src/tools/ask.ts +44 -38
  75. package/src/tools/bash.ts +9 -2
  76. package/src/tools/browser/tab-worker.ts +1 -1
  77. package/src/tools/eval-render.ts +34 -27
  78. package/src/tools/eval.ts +100 -103
  79. package/src/tools/index.ts +8 -1
  80. package/src/tools/read.ts +136 -60
  81. package/src/tools/todo.ts +60 -64
  82. package/src/tui/code-cell.ts +1 -1
  83. package/src/tui/output-block.ts +11 -0
  84. package/src/utils/image-resize.ts +30 -0
  85. package/src/web/search/providers/exa.ts +85 -1
@@ -115,103 +115,6 @@ if "__omp_prelude_loaded__" not in globals():
115
115
  _emit_status("write", path=str(p), chars=len(content))
116
116
  return p
117
117
 
118
- def append(path: str | Path, content: str) -> Path:
119
- """Append to file."""
120
- p = _resolve_omp_path(path)
121
- p.parent.mkdir(parents=True, exist_ok=True)
122
- with p.open("a", encoding="utf-8") as f:
123
- f.write(content)
124
- _emit_status("append", path=str(p), chars=len(content))
125
- return p
126
-
127
- def sort(text: str, *, reverse: bool = False, unique: bool = False) -> str:
128
- """Sort lines of text."""
129
- lines = text.splitlines()
130
- if unique:
131
- lines = list(dict.fromkeys(lines))
132
- lines = sorted(lines, reverse=reverse)
133
- out = "\n".join(lines)
134
- _emit_status("sort", lines=len(lines), unique=unique, reverse=reverse)
135
- return out
136
-
137
- def uniq(text: str, *, count: bool = False) -> str | list[tuple[int, str]]:
138
- """Remove duplicate adjacent lines (like uniq)."""
139
- lines = text.splitlines()
140
- if not lines:
141
- _emit_status("uniq", groups=0)
142
- return [] if count else ""
143
- groups: list[tuple[int, str]] = []
144
- current = lines[0]
145
- current_count = 1
146
- for line in lines[1:]:
147
- if line == current:
148
- current_count += 1
149
- continue
150
- groups.append((current_count, current))
151
- current = line
152
- current_count = 1
153
- groups.append((current_count, current))
154
- _emit_status("uniq", groups=len(groups), count_mode=count)
155
- if count:
156
- return groups
157
- return "\n".join(line for _, line in groups)
158
-
159
- def counter(
160
- items: str | list,
161
- *,
162
- limit: int | None = None,
163
- reverse: bool = True,
164
- ) -> list[tuple[int, str]]:
165
- """Count occurrences and sort by frequency. Like sort | uniq -c | sort -rn.
166
-
167
- items: text (splits into lines) or list of strings
168
- reverse: True for descending (most common first), False for ascending
169
- Returns: [(count, item), ...] sorted by count
170
- """
171
- from collections import Counter
172
- if isinstance(items, str):
173
- items = items.splitlines()
174
- counts = Counter(items)
175
- sorted_items = sorted(counts.items(), key=lambda x: (x[1], x[0]), reverse=reverse)
176
- if limit is not None:
177
- sorted_items = sorted_items[:limit]
178
- result = [(count, item) for item, count in sorted_items]
179
- _emit_status("counter", unique=len(counts), total=sum(counts.values()), top=result[:10])
180
- return result
181
- def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str:
182
- """Return directory tree."""
183
- base = Path(path)
184
- lines = []
185
- def walk(p: Path, prefix: str, depth: int):
186
- if depth > max_depth:
187
- return
188
- items = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
189
- items = [i for i in items if show_hidden or not i.name.startswith(".")]
190
- for i, item in enumerate(items):
191
- is_last = i == len(items) - 1
192
- connector = "└── " if is_last else "├── "
193
- suffix = "/" if item.is_dir() else ""
194
- lines.append(f"{prefix}{connector}{item.name}{suffix}")
195
- if item.is_dir():
196
- ext = " " if is_last else "│ "
197
- walk(item, prefix + ext, depth + 1)
198
- lines.append(str(base) + "/")
199
- walk(base, "", 1)
200
- out = "\n".join(lines)
201
- _emit_status("tree", path=str(base), entries=len(lines) - 1, preview=out[:1000])
202
- return out
203
-
204
- def diff(a: str | Path, b: str | Path) -> str:
205
- """Compare two files, return unified diff."""
206
- import difflib
207
- path_a, path_b = Path(a), Path(b)
208
- lines_a = path_a.read_text(encoding="utf-8").splitlines(keepends=True)
209
- lines_b = path_b.read_text(encoding="utf-8").splitlines(keepends=True)
210
- result = difflib.unified_diff(lines_a, lines_b, fromfile=str(path_a), tofile=str(path_b))
211
- out = "".join(result)
212
- _emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=not out, preview=out[:500])
213
- return out
214
-
215
118
  def output(
216
119
  *ids: str,
217
120
  format: str = "raw",
@@ -520,37 +423,66 @@ if "__omp_prelude_loaded__" not in globals():
520
423
  text = res.get("text") if isinstance(res, dict) else res
521
424
  return json.loads(text) if schema is not None else text
522
425
 
523
- def agent(prompt, *, agent_type="task", model=None, label=None, schema=None, return_handle=False):
426
+ def agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False):
524
427
  """Run a subagent and return its final output.
525
428
 
526
- `agent_type` selects the subagent definition (default "task"). Pass
429
+ `agent` selects the subagent definition (default "task"). Pass
527
430
  `model` to override that agent's model, `label` for the output artifact
528
431
  id, and `schema` to request structured JSON output; when `schema` is
529
432
  supplied the parsed object is returned. Share background by writing a
530
433
  local:// file and referencing it in the prompt.
531
434
 
532
- Set `return_handle=True` to receive a DAG node dict instead of bare
435
+ Pass `isolated=True` to run the subagent inside an isolation worktree
436
+ (copy-on-write of the parent repo) so parallel `agent()` spawns can
437
+ edit overlapping files safely. Strict opt-in, mirroring the `task`
438
+ tool: the default is non-isolated regardless of `task.isolation.mode`.
439
+ `isolated=True` while the setting is `"none"` errors out instead of
440
+ silently downgrading.
441
+
442
+ When isolated, `apply=False` keeps captured changes inside the
443
+ worktree and surfaces the root patch path, branch name, and nested
444
+ repository patches through the DAG node dict (combine with
445
+ `handle=True` to receive them — see below; the bare return type
446
+ stays bytes/string/parsed object and has nowhere to expose artifacts).
447
+ `merge=False` forces patch mode even when `task.isolation.merge` is
448
+ `"branch"`, avoiding the per-call git lock + repo mutation that branch
449
+ mode performs.
450
+
451
+ Set `handle=True` to receive a DAG node dict instead of bare
533
452
  text: ``{"text", "output", "handle", "id", "agent"}`` where ``handle``
534
453
  is the spawned agent's recoverable ``agent://<id>`` URI. A downstream
535
454
  ``pipeline``/``parallel`` stage embeds that ``handle`` (or ``output``)
536
455
  in its prompt so a large transcript flows through the graph by
537
456
  reference, never re-inlined. When ``schema`` is also set the parsed
538
- object lands under ``"data"``. If the bridge returns no recoverable id
539
- the node still resolves with ``handle=None`` the helper never throws.
457
+ object lands under ``"data"``. When the spawn ran isolated the node
458
+ also carries ``"isolated"`` and, when present, ``"patch_path"``,
459
+ ``"branch_name"``, ``"nested_patches"``, ``"changes_applied"``
460
+ (``True``/``False``/``None`` — ``None`` means ``apply=False``), and
461
+ ``"isolation_summary"``. If
462
+ the bridge returns no recoverable id the node still resolves with
463
+ ``handle=None`` — the helper never throws.
540
464
  """
541
465
  args = {"prompt": prompt}
542
- if agent_type is not None:
543
- args["agentType"] = agent_type
466
+ if agent is not None:
467
+ args["agent"] = agent
544
468
  if model is not None:
545
469
  args["model"] = model
546
470
  if label is not None:
547
471
  args["label"] = label
548
472
  if schema is not None:
549
473
  args["schema"] = schema
474
+ if isolated is not None:
475
+ args["isolated"] = bool(isolated)
476
+ if apply is not None:
477
+ args["apply"] = bool(apply)
478
+ if merge is not None:
479
+ args["merge"] = bool(merge)
480
+ if handle:
481
+ args["handle"] = True
550
482
  res = _bridge_call("__agent__", args)
551
483
  text = res.get("text") if isinstance(res, dict) else res
552
484
  parsed = json.loads(text) if schema is not None else text
553
- if not return_handle:
485
+ if not handle:
554
486
  return parsed
555
487
  details = res.get("details") if isinstance(res, dict) else None
556
488
  if not isinstance(details, dict) or details.get("id") is None:
@@ -564,6 +496,16 @@ if "__omp_prelude_loaded__" not in globals():
564
496
  }
565
497
  if schema is not None:
566
498
  node["data"] = parsed
499
+ for src_key, dst_key in (
500
+ ("isolated", "isolated"),
501
+ ("patchPath", "patch_path"),
502
+ ("branchName", "branch_name"),
503
+ ("nestedPatches", "nested_patches"),
504
+ ("changesApplied", "changes_applied"),
505
+ ("isolationSummary", "isolation_summary"),
506
+ ):
507
+ if src_key in details:
508
+ node[dst_key] = details[src_key]
567
509
  return node
568
510
 
569
511
  def _concurrency_limit():
@@ -557,12 +557,6 @@ def _magic_run(args: str) -> None:
557
557
  def _magic_cell_bash(args: str, body: str) -> int:
558
558
  return _run_shell_body(body, shell_arg="/bin/bash")
559
559
 
560
-
561
- @cell_magic("sh")
562
- def _magic_cell_sh(args: str, body: str) -> int:
563
- return _run_shell_body(body, shell_arg="/bin/sh")
564
-
565
-
566
560
  @cell_magic("capture")
567
561
  def _magic_cell_capture(args: str, body: str) -> str:
568
562
  """Capture stdout/stderr of body; bind to ``args`` (a name) if provided."""
@@ -2,7 +2,7 @@
2
2
  # OMP Ruby prelude helpers (loaded once into the runner's TOPLEVEL_BINDING).
3
3
  #
4
4
  # Mirrors eval/py/prelude.py: defines the cross-runtime helper surface
5
- # (display/read/write/append/tree/diff/env/output, the `tool` bridge proxy,
5
+ # (display/read/write/env/output, the `tool` bridge proxy,
6
6
  # completion/agent/parallel/pipeline/log/phase/budget). Host-side helpers reach
7
7
  # the coding-agent over the same loopback HTTP tool bridge the Python prelude
8
8
  # uses (PI_TOOL_BRIDGE_URL/TOKEN/SESSION). Path helpers honor PI_EVAL_LOCAL_ROOTS
@@ -112,191 +112,6 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
112
112
  resolved.to_s
113
113
  end
114
114
 
115
- def append(path, content)
116
- resolved = __omp_resolve_path(path)
117
- require "fileutils"
118
- FileUtils.mkdir_p(File.dirname(resolved.to_s))
119
- File.open(resolved.to_s, "a") { |f| f.write(content.to_s) }
120
- __omp_emit_status("append", "path" => resolved.to_s, "chars" => content.to_s.length)
121
- resolved.to_s
122
- end
123
-
124
- def tree(path = ".", max_depth: 3, show_hidden: false)
125
- base = path.to_s
126
- lines = []
127
- walk = lambda do |dir, prefix, depth|
128
- return if depth > max_depth
129
- entries = (Dir.children(dir) rescue [])
130
- entries = entries.reject { |e| e.start_with?(".") } unless show_hidden
131
- entries = entries.sort_by { |e| [File.directory?(File.join(dir, e)) ? 0 : 1, e.downcase] }
132
- entries.each_with_index do |name, i|
133
- full = File.join(dir, name)
134
- is_last = i == entries.length - 1
135
- is_dir = File.directory?(full)
136
- lines << "#{prefix}#{is_last ? "└── " : "├── "}#{name}#{is_dir ? "/" : ""}"
137
- walk.call(full, prefix + (is_last ? " " : "│ "), depth + 1) if is_dir
138
- end
139
- end
140
- lines << "#{base}/"
141
- walk.call(base, "", 1)
142
- out = lines.join("\n")
143
- __omp_emit_status("tree", "path" => base, "entries" => lines.length - 1, "preview" => __omp_scrub(out[0, 1000].to_s))
144
- out
145
- end
146
-
147
- def diff(a, b)
148
- pa = a.to_s
149
- pb = b.to_s
150
- lines_a = File.read(pa, encoding: Encoding::UTF_8).lines
151
- lines_b = File.read(pb, encoding: Encoding::UTF_8).lines
152
- out = __omp_unified_diff(lines_a, lines_b, pa, pb)
153
- __omp_emit_status("diff", "file_a" => pa, "file_b" => pb, "identical" => out.empty?, "preview" => __omp_scrub(out[0, 500].to_s))
154
- out
155
- end
156
-
157
- # LCS-based op list ([:equal/:delete/:insert, aIndex, bIndex]) per consumed line.
158
- def __omp_diff_ops(a, b)
159
- n = a.length
160
- m = b.length
161
- if n * m > 4_000_000
162
- # Too large for the DP table — fall back to a coarse all-delete/all-insert.
163
- ops = []
164
- n.times { |i| ops << [:delete, i, 0] }
165
- m.times { |j| ops << [:insert, n, j] }
166
- return ops
167
- end
168
- dp = Array.new(n + 1) { Array.new(m + 1, 0) }
169
- (n - 1).downto(0) do |i|
170
- row = dp[i]
171
- nrow = dp[i + 1]
172
- (m - 1).downto(0) do |j|
173
- row[j] = a[i] == b[j] ? nrow[j + 1] + 1 : (nrow[j] >= row[j + 1] ? nrow[j] : row[j + 1])
174
- end
175
- end
176
- ops = []
177
- i = 0
178
- j = 0
179
- while i < n && j < m
180
- if a[i] == b[j]
181
- ops << [:equal, i, j]; i += 1; j += 1
182
- elsif dp[i + 1][j] >= dp[i][j + 1]
183
- ops << [:delete, i, j]; i += 1
184
- else
185
- ops << [:insert, i, j]; j += 1
186
- end
187
- end
188
- ops << [:delete, i, j].tap { i += 1 } while i < n
189
- ops << [:insert, i, j].tap { j += 1 } while j < m
190
- ops
191
- end
192
-
193
- def __omp_unified_diff(a, b, from_file, to_file, context = 3)
194
- ops = __omp_diff_ops(a, b)
195
- return "" unless ops.any? { |tag, _, _| tag != :equal }
196
-
197
- entries = ops.map do |tag, ai, bi|
198
- { tag: tag, ai: ai, bi: bi, text: (tag == :insert ? b[bi] : a[ai]) }
199
- end
200
- changed = entries.each_index.select { |k| entries[k][:tag] != :equal }
201
-
202
- groups = []
203
- start = nil
204
- prev = nil
205
- changed.each do |k|
206
- if start.nil?
207
- start = k
208
- prev = k
209
- elsif k - prev <= (2 * context) + 1
210
- prev = k
211
- else
212
- groups << [start, prev]
213
- start = k
214
- prev = k
215
- end
216
- end
217
- groups << [start, prev] unless start.nil?
218
-
219
- out = +""
220
- out << "--- #{from_file}\n"
221
- out << "+++ #{to_file}\n"
222
- groups.each do |gs, ge|
223
- lo = [gs - context, 0].max
224
- hi = [ge + context, entries.length - 1].min
225
- slice = entries[lo..hi]
226
- a_start = nil
227
- a_count = 0
228
- b_start = nil
229
- b_count = 0
230
- slice.each do |e|
231
- if e[:tag] != :insert
232
- a_start ||= e[:ai]
233
- a_count += 1
234
- end
235
- if e[:tag] != :delete
236
- b_start ||= e[:bi]
237
- b_count += 1
238
- end
239
- end
240
- out << "@@ -#{(a_start || 0) + 1},#{a_count} +#{(b_start || 0) + 1},#{b_count} @@\n"
241
- slice.each do |e|
242
- prefix = e[:tag] == :equal ? " " : (e[:tag] == :delete ? "-" : "+")
243
- text = e[:text].to_s
244
- text = "#{text}\n" unless text.end_with?("\n")
245
- out << "#{prefix}#{text}"
246
- end
247
- end
248
- out
249
- end
250
-
251
- # -------------------------------------------------------------------------
252
- # Text helpers (sort / uniq / counter)
253
- # -------------------------------------------------------------------------
254
-
255
- def sort(text, reverse: false, unique: false)
256
- lines = text.to_s.lines.map(&:chomp)
257
- lines = lines.uniq if unique
258
- lines = lines.sort
259
- lines = lines.reverse if reverse
260
- out = lines.join("\n")
261
- __omp_emit_status("sort", "lines" => lines.length, "unique" => unique, "reverse" => reverse)
262
- out
263
- end
264
-
265
- def uniq(text, count: false)
266
- lines = text.to_s.lines.map(&:chomp)
267
- if lines.empty?
268
- __omp_emit_status("uniq", "groups" => 0)
269
- return count ? [] : ""
270
- end
271
- groups = []
272
- current = lines[0]
273
- run = 1
274
- lines[1..].each do |line|
275
- if line == current
276
- run += 1
277
- else
278
- groups << [run, current]
279
- current = line
280
- run = 1
281
- end
282
- end
283
- groups << [run, current]
284
- __omp_emit_status("uniq", "groups" => groups.length, "count_mode" => count)
285
- count ? groups : groups.map { |_, l| l }.join("\n")
286
- end
287
-
288
- def counter(items, limit: nil, reverse: true)
289
- arr = items.is_a?(String) ? items.lines.map(&:chomp) : items.to_a
290
- counts = Hash.new(0)
291
- arr.each { |i| counts[i] += 1 }
292
- sorted = counts.sort_by { |item, c| [c, item] }
293
- sorted = sorted.reverse if reverse
294
- sorted = sorted.first(limit) if limit
295
- result = sorted.map { |item, c| [c, item] }
296
- __omp_emit_status("counter", "unique" => counts.size, "total" => arr.length, "top" => result.first(10))
297
- result
298
- end
299
-
300
115
  # -------------------------------------------------------------------------
301
116
  # Task/agent output reader
302
117
  # -------------------------------------------------------------------------
@@ -577,16 +392,23 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
577
392
  schema.nil? ? text : JSON.parse(text)
578
393
  end
579
394
 
580
- def agent(prompt, agent_type: "task", model: nil, label: nil, schema: nil, return_handle: false)
395
+ def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, isolated: nil, apply: nil, merge: nil, handle: false)
581
396
  args = { "prompt" => prompt }
582
- args["agentType"] = agent_type unless agent_type.nil?
397
+ args["agent"] = agent unless agent.nil?
583
398
  args["model"] = model unless model.nil?
584
399
  args["label"] = label unless label.nil?
585
400
  args["schema"] = schema unless schema.nil?
401
+ # Isolation knobs mirror the `task` tool: strict opt-in via `isolated`,
402
+ # with `apply`/`merge` controlling the post-run patch/branch merge.
403
+ args["isolated"] = !!isolated unless isolated.nil?
404
+ args["apply"] = !!apply unless apply.nil?
405
+ args["merge"] = !!merge unless merge.nil?
406
+ # Tell the bridge a handle is wanted so it preserves the backing artifacts.
407
+ args["handle"] = true if handle
586
408
  res = OmpBridge.call("__agent__", args)
587
409
  text = res.is_a?(Hash) ? res["text"] : res
588
410
  parsed = schema.nil? ? text : JSON.parse(text)
589
- return parsed unless return_handle
411
+ return parsed unless handle
590
412
  details = res.is_a?(Hash) ? res["details"] : nil
591
413
  if !details.is_a?(Hash) || details["id"].nil?
592
414
  return { "text" => text, "output" => text, "handle" => nil, "id" => nil, "agent" => nil }
@@ -599,6 +421,16 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
599
421
  "agent" => details["agent"],
600
422
  }
601
423
  node["data"] = parsed unless schema.nil?
424
+ {
425
+ "isolated" => "isolated",
426
+ "patchPath" => "patch_path",
427
+ "branchName" => "branch_name",
428
+ "nestedPatches" => "nested_patches",
429
+ "changesApplied" => "changes_applied",
430
+ "isolationSummary" => "isolation_summary",
431
+ }.each do |src_key, dst_key|
432
+ node[dst_key] = details[src_key] if details.key?(src_key)
433
+ end
602
434
  node
603
435
  end
604
436
 
@@ -129,10 +129,122 @@ def __omp_emit_status(op, data = {})
129
129
  __omp_emit_display({ "application/x-omp-status" => status }, "display")
130
130
  end
131
131
 
132
+ OMP_IMAGE_MIMES = %w[image/png image/jpeg].freeze
133
+
134
+ # True when `str` already looks like base64 text (ASCII, base64 alphabet, length
135
+ # a multiple of 4). Raw image blobs (PNG/JPEG bytes) contain high bytes, so they
136
+ # fail the ASCII check and get encoded instead of passed through unchanged.
137
+ def __omp_base64?(str)
138
+ s = str.to_s
139
+ # ascii_only? is safe on any encoding (no regex over invalid bytes). Raw image
140
+ # blobs carry high bytes and fail here, so they get encoded rather than scanned.
141
+ return false unless s.ascii_only?
142
+ stripped = s.gsub(/\s+/, "")
143
+ return false if stripped.empty? || (stripped.bytesize % 4) != 0
144
+ stripped.match?(%r{\A[A-Za-z0-9+/]*={0,2}\z})
145
+ end
146
+
147
+ # Coerce an image payload to the base64 ASCII the host renders. IRuby-style
148
+ # `to_iruby` hands back raw binary blobs (Gruff#to_blob, ChunkyPNG, RMagick),
149
+ # which would also break JSON.generate; strict-encode them unless already base64.
150
+ def __omp_image_payload(content)
151
+ require "base64"
152
+ s = content.to_s
153
+ return s.gsub(/\s+/, "") if __omp_base64?(s)
154
+ Base64.strict_encode64(s.b)
155
+ end
156
+
157
+ # Detect a host-renderable image MIME from a binary blob's magic bytes. Lets us
158
+ # treat the generic `to_blob` (Gruff/RMagick/ChunkyPNG/Vips) as an image only
159
+ # when it really is one, avoiding false positives on unrelated `to_blob` methods.
160
+ def __omp_sniff_image_mime(bytes)
161
+ b = bytes.to_s.b
162
+ return "image/png" if b.start_with?("\x89PNG\r\n\x1a\n".b)
163
+ return "image/jpeg" if b.start_with?("\xFF\xD8\xFF".b)
164
+ nil
165
+ end
166
+
167
+ # Stringify keys, base64-encode image payloads, and scrub text payloads so the
168
+ # bundle is always JSON-safe before it reaches __omp_emit.
169
+ def __omp_normalize_bundle(hash)
170
+ bundle = {}
171
+ hash.each do |key, val|
172
+ k = key.to_s
173
+ bundle[k] =
174
+ if OMP_IMAGE_MIMES.include?(k)
175
+ __omp_image_payload(val)
176
+ elsif val.is_a?(String)
177
+ __omp_scrub(val)
178
+ else
179
+ val
180
+ end
181
+ end
182
+ bundle
183
+ end
184
+
185
+ # Guarantee a text/plain entry so the model always sees a textual hint, even for
186
+ # image-only bundles (mirrors the Python runner).
187
+ def __omp_finalize_bundle(bundle, value)
188
+ bundle["text/plain"] ||= __omp_scrub((value.inspect rescue value.class.name))
189
+ bundle
190
+ end
191
+
192
+ # Rich-display resolution for non-collection objects. Honors the repo
193
+ # `to_omp_mime` convention first, then the IRuby protocol
194
+ # (`to_iruby_mimebundle` -> [data, metadata], `to_iruby` -> [mime, data]) so plot
195
+ # and image objects (gruff, rubyplot, gnuplotrb, chunky_png, daru, ...) render
196
+ # inline — the Ruby analog of IPython's _repr_*_ methods. Returns nil when the
197
+ # value advertises no rich representation.
198
+ def __omp_rich_mime_bundle(value)
199
+ if value.respond_to?(:to_omp_mime)
200
+ mime = (value.to_omp_mime rescue nil)
201
+ return __omp_finalize_bundle(__omp_normalize_bundle(mime), value) if mime.is_a?(Hash) && !mime.empty?
202
+ end
203
+ if value.respond_to?(:to_iruby_mimebundle)
204
+ data =
205
+ begin
206
+ value.to_iruby_mimebundle
207
+ rescue ArgumentError
208
+ (value.to_iruby_mimebundle(include: []) rescue nil)
209
+ rescue StandardError
210
+ nil
211
+ end
212
+ data = data.first if data.is_a?(Array)
213
+ return __omp_finalize_bundle(__omp_normalize_bundle(data), value) if data.is_a?(Hash) && !data.empty?
214
+ end
215
+ if value.respond_to?(:to_iruby)
216
+ pair = (value.to_iruby rescue nil)
217
+ if pair.is_a?(Array) && pair.size == 2 && !pair[0].nil?
218
+ return __omp_finalize_bundle(__omp_normalize_bundle({ pair[0].to_s => pair[1] }), value)
219
+ end
220
+ end
221
+ # Last resort: probe well-known image emitters. Named methods (to_png/to_jpeg)
222
+ # are trusted; the generic to_blob is accepted only when its bytes sniff as an
223
+ # image. Covers gems that render via IRuby's registry rather than to_iruby
224
+ # (Gruff#to_blob, ChunkyPNG#to_blob, RMagick, Vips, ...).
225
+ if value.respond_to?(:to_png)
226
+ png = (value.to_png rescue nil)
227
+ return __omp_finalize_bundle({ "image/png" => __omp_image_payload(png) }, value) if png
228
+ end
229
+ jpeg_method = %i[to_jpeg to_jpg].find { |m| value.respond_to?(m) }
230
+ if jpeg_method
231
+ jpg = (value.public_send(jpeg_method) rescue nil)
232
+ return __omp_finalize_bundle({ "image/jpeg" => __omp_image_payload(jpg) }, value) if jpg
233
+ end
234
+ if value.respond_to?(:to_blob)
235
+ blob = (value.to_blob rescue nil)
236
+ if blob.is_a?(String) && (mime = __omp_sniff_image_mime(blob))
237
+ return __omp_finalize_bundle({ mime => __omp_image_payload(blob) }, value)
238
+ end
239
+ end
240
+
241
+ nil
242
+ end
243
+
132
244
  # Build a Jupyter-style MIME bundle for a value. Strings render as plain text,
133
- # Hash/Array render as JSON (plus a text/plain repr) so the model sees structure,
134
- # and anything else falls back to its inspect string. Objects may opt into a
135
- # richer bundle by defining `to_omp_mime` returning a Hash of mime => value.
245
+ # Hash/Array render as JSON (plus a text/plain repr) so the model sees structure.
246
+ # Other objects may expose a rich representation via `to_omp_mime` or the IRuby
247
+ # protocol (`to_iruby`/`to_iruby_mimebundle`); otherwise they fall back to inspect.
136
248
  def __omp_mime_bundle(value)
137
249
  case value
138
250
  when String
@@ -151,12 +263,7 @@ def __omp_mime_bundle(value)
151
263
  when nil
152
264
  { "text/plain" => "nil" }
153
265
  else
154
- if value.respond_to?(:to_omp_mime)
155
- mime = (value.to_omp_mime rescue nil)
156
- mime.is_a?(Hash) ? mime : { "text/plain" => __omp_scrub(value.inspect) }
157
- else
158
- { "text/plain" => __omp_scrub(value.inspect) }
159
- end
266
+ __omp_rich_mime_bundle(value) || { "text/plain" => __omp_scrub(value.inspect) }
160
267
  end
161
268
  end
162
269