pcode 0.1.6__tar.gz → 0.1.7__tar.gz

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 (177) hide show
  1. {pcode-0.1.6 → pcode-0.1.7}/PKG-INFO +1 -1
  2. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/app.py +30 -8
  3. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/appearance.py +10 -0
  4. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/Brief format.md +2 -0
  5. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/__init__.py +95 -46
  6. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/plan_mode.py +129 -25
  7. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/file_refs.py +1 -4
  8. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/prompt_keys.py +5 -25
  9. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/ui.py +1 -5
  10. pcode-0.1.6/src/pcode/paste.py +0 -45
  11. {pcode-0.1.6 → pcode-0.1.7}/.gitignore +0 -0
  12. {pcode-0.1.6 → pcode-0.1.7}/LICENSE +0 -0
  13. {pcode-0.1.6 → pcode-0.1.7}/README.md +0 -0
  14. {pcode-0.1.6 → pcode-0.1.7}/pyproject.toml +0 -0
  15. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/__init__.py +0 -0
  16. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/_job_supervisor.py +0 -0
  17. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/agent.py +0 -0
  18. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/anthropic_oauth.py +0 -0
  19. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/aside.py +0 -0
  20. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/aside_guard.py +0 -0
  21. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/aside_ui.py +0 -0
  22. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/auth.py +0 -0
  23. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/background_delegation.py +0 -0
  24. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/block.py +0 -0
  25. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/browser.py +0 -0
  26. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/cache_diagnostics.py +0 -0
  27. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/cache_settings.py +0 -0
  28. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/cache_warnings.py +0 -0
  29. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/__init__.py +0 -0
  30. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/cli.py +0 -0
  31. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/errors.py +0 -0
  32. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/messages.py +0 -0
  33. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/model.py +0 -0
  34. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/resume.py +0 -0
  35. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/session.py +0 -0
  36. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/session_pool.py +0 -0
  37. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/web.py +0 -0
  38. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/workspace.py +0 -0
  39. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/cli.py +0 -0
  40. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/clipboard.py +0 -0
  41. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/code_mode.py +0 -0
  42. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/codex_login.py +0 -0
  43. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/command_transcript.py +0 -0
  44. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/commands.py +0 -0
  45. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/compaction.py +0 -0
  46. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/completion.py +0 -0
  47. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/config.py +0 -0
  48. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/config_ui.py +0 -0
  49. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/context_breakdown.py +0 -0
  50. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/context_usage.py +0 -0
  51. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/controller.py +0 -0
  52. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/conversation_ids.py +0 -0
  53. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/conversation_tree.py +0 -0
  54. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/copy_ui.py +0 -0
  55. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/delegation.py +0 -0
  56. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/delta.py +0 -0
  57. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/diagnostics.py +0 -0
  58. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/edit_preview.py +0 -0
  59. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/edit_transcript.py +0 -0
  60. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/edit_ui.py +0 -0
  61. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/edits.py +0 -0
  62. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/__init__.py +0 -0
  63. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/cli.py +0 -0
  64. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/listener.py +0 -0
  65. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/mailbox.py +0 -0
  66. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/outbound.py +0 -0
  67. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/parsing.py +0 -0
  68. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/render.py +0 -0
  69. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/state.py +0 -0
  70. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/error_report.py +0 -0
  71. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/ext.py +0 -0
  72. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extension_guide.md +0 -0
  73. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/Briefs.base +0 -0
  74. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/Home.md +0 -0
  75. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/memory-states.css +0 -0
  76. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/browser.py +0 -0
  77. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/ponytail.py +0 -0
  78. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/sandbox.py +0 -0
  79. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/session_history.py +0 -0
  80. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/web_research.py +0 -0
  81. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/filesystem.py +0 -0
  82. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/frame.py +0 -0
  83. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/gateway.py +0 -0
  84. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/git_diff.py +0 -0
  85. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/history.py +0 -0
  86. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/history_cursor.py +0 -0
  87. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/history_embeddings.py +0 -0
  88. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/host.py +0 -0
  89. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/host_protocol.py +0 -0
  90. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/host_ui.py +0 -0
  91. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/input_keys.py +0 -0
  92. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/inspection.py +0 -0
  93. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/inspector_ui.py +0 -0
  94. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/isolated_delegation.py +0 -0
  95. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/job_notices.py +0 -0
  96. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/jobs.py +0 -0
  97. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/jobs_ui.py +0 -0
  98. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/keyboard_protocol.py +0 -0
  99. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/keymap.py +0 -0
  100. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/layout_speed.py +0 -0
  101. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/links.py +0 -0
  102. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/links_ui.py +0 -0
  103. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/live.py +0 -0
  104. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/llm_proxy.py +0 -0
  105. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/logins.py +0 -0
  106. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/mcp.py +0 -0
  107. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/mcp_notice.py +0 -0
  108. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/mcp_oauth.py +0 -0
  109. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/meridian.py +0 -0
  110. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/meridian_process.py +0 -0
  111. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/meridian_reminders.py +0 -0
  112. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/meridian_setup.py +0 -0
  113. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/model_metadata.py +0 -0
  114. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/model_ui.py +0 -0
  115. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/models.py +0 -0
  116. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/native_results.py +0 -0
  117. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/oauth_pages.py +0 -0
  118. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/output_limits.py +0 -0
  119. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/path_completion.py +0 -0
  120. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/plan_preview.py +0 -0
  121. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/plan_sizes.py +0 -0
  122. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/planning.py +0 -0
  123. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/popup_ui.py +0 -0
  124. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/preferences.py +0 -0
  125. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/prefix_keys.py +0 -0
  126. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/proctitle.py +0 -0
  127. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/profile_benchmark.py +0 -0
  128. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/profiling.py +0 -0
  129. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/project_trust.py +0 -0
  130. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/remote.py +0 -0
  131. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/remote_print.py +0 -0
  132. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/remote_profile.py +0 -0
  133. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/replay_benchmark.py +0 -0
  134. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/repo_context.py +0 -0
  135. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/retries.py +0 -0
  136. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/rpc.py +0 -0
  137. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/runtime.py +0 -0
  138. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/sandbox.py +0 -0
  139. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/session_naming.py +0 -0
  140. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/session_ui.py +0 -0
  141. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/sessions.py +0 -0
  142. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/shell.py +0 -0
  143. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/shell_mode.py +0 -0
  144. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/shell_tools.py +0 -0
  145. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/skills.py +0 -0
  146. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/stall_log.py +0 -0
  147. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/startup_input.py +0 -0
  148. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/steering.py +0 -0
  149. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/stream_display.py +0 -0
  150. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/stream_events.py +0 -0
  151. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/strict_tools.py +0 -0
  152. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/syntax.py +0 -0
  153. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/syntax_colors.py +0 -0
  154. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/task_prompt.py +0 -0
  155. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/task_worktrees.py +0 -0
  156. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/terminal_notify.py +0 -0
  157. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/terminal_text.py +0 -0
  158. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/text_selection.py +0 -0
  159. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/theme.py +0 -0
  160. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/theme_gallery.py +0 -0
  161. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/thinking_markdown.py +0 -0
  162. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/token_accounting.py +0 -0
  163. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/tool_display.py +0 -0
  164. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/tool_output_limits.py +0 -0
  165. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/tool_panel.py +0 -0
  166. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/transcript_log.py +0 -0
  167. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/transcript_notice.py +0 -0
  168. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/tree_ui.py +0 -0
  169. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/turn.py +0 -0
  170. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/update_check.py +0 -0
  171. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/usage.py +0 -0
  172. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/word_wrap.py +0 -0
  173. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/worker_ui.py +0 -0
  174. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/workers.py +0 -0
  175. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/workspace.py +0 -0
  176. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/workspace_filesystem.py +0 -0
  177. {pcode-0.1.6 → pcode-0.1.7}/src/pcode/worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pcode
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Terminal-native AI coding agent with background jobs, parallel worktrees, forkable sessions, and multi-model support
5
5
  Project-URL: Homepage, https://github.com/cruxwell/pcode
6
6
  Project-URL: Documentation, https://cruxwell.github.io/pcode/
@@ -1227,14 +1227,24 @@ class PreviewApp:
1227
1227
  self.transcript.syntax_gallery()
1228
1228
 
1229
1229
  def theme(self, argument: str) -> None:
1230
- self.transcript.theme = argument or (
1231
- "light" if self.transcript.resolved_theme == "dark" else "dark"
1232
- )
1233
- self.persist_defaults(theme=self.transcript.theme)
1234
- selected = self.transcript.theme
1235
- if selected == "auto":
1236
- selected += f" ({self.transcript.resolved_theme})"
1237
- self.transcript.flash(f"Theme: {selected}.")
1230
+ """Pick and save a palette; bare `/theme` flips it for this session only.
1231
+
1232
+ Saving the flip would pin every later session to it, and with it turn
1233
+ off following the terminal's light/dark switches.
1234
+ """
1235
+ theme = argument or ("light" if self.transcript.resolved_theme == "dark" else "dark")
1236
+ self.transcript.theme = theme
1237
+ if not argument:
1238
+ message = f"{theme} for this session; /theme {theme} saves it"
1239
+ else:
1240
+ self.persist_defaults(theme=theme)
1241
+ message = theme
1242
+ if theme == "auto":
1243
+ message += f" ({self.transcript.resolved_theme})"
1244
+ if self._appearance is not None:
1245
+ # The detected value may be stale; the answer repaints if it differs.
1246
+ self._appearance.refresh()
1247
+ self.transcript.flash(f"Theme: {message}.")
1238
1248
  self.transcript.regenerate()
1239
1249
 
1240
1250
  def follow_appearance(self, detected: str) -> None:
@@ -2709,6 +2719,9 @@ def main() -> None:
2709
2719
  "hosts",
2710
2720
  )
2711
2721
  parser.add_argument("--hosts", action="store_true", help="List running session hosts and exit")
2722
+ parser.add_argument(
2723
+ "--json", action="store_true", help="With --hosts: one JSON array, for scripts and plugins"
2724
+ )
2712
2725
  host_kinds = ("all", "stale", "idle")
2713
2726
  parser.add_argument(
2714
2727
  "--kill-hosts",
@@ -3179,6 +3192,8 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
3179
3192
  parser.error("--fork cannot be combined with --attach")
3180
3193
  if args.compact and not args.sessions:
3181
3194
  parser.error("--compact applies to --sessions")
3195
+ if args.json and not args.hosts:
3196
+ parser.error("--json applies to --hosts")
3182
3197
  if args.print:
3183
3198
  if args.theme_preview or args.sessions:
3184
3199
  parser.error("--print cannot be combined with --theme-preview or --sessions")
@@ -3225,6 +3240,13 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
3225
3240
  if args.kill_hosts:
3226
3241
  _kill_hosts(args.kill_hosts, entries, code)
3227
3242
  return
3243
+ if args.json:
3244
+ import json
3245
+
3246
+ # A contract for callers such as the Obsidian bridge: add keys, never rename.
3247
+ keys = ("id", "session_id", "state", "workspace", "title", "last_prompt", "attached")
3248
+ print(json.dumps([{key: getattr(entry, key) for key in keys} for entry in entries]))
3249
+ return
3228
3250
  for entry in entries:
3229
3251
  print(f"{entry.id} {host_row(entry, None, code=code).strip()}")
3230
3252
  if not entries:
@@ -144,6 +144,13 @@ class AppearanceWatch:
144
144
  self.suspended = False
145
145
  if enabled:
146
146
  self.enabled = _write_all(self.fd, ENABLE_REPORTS)
147
+ # A switch while the other program had the terminal went unseen.
148
+ self.refresh()
149
+
150
+ def refresh(self) -> None:
151
+ """Ask for the background now; the answer arrives as input like any other."""
152
+ if self.enabled and not self.suspended:
153
+ _write_all(self.fd, QUERY_BACKGROUND)
147
154
 
148
155
  async def query(self) -> None:
149
156
  for delay in QUERY_DELAYS:
@@ -155,6 +162,9 @@ class AppearanceWatch:
155
162
  if self.fd is None or not _isatty(self.fd) or os.environ.get("TERM") == "dumb":
156
163
  return
157
164
  self.enabled = _write_all(self.fd, ENABLE_REPORTS)
165
+ # The startup probe is skipped when input is already waiting, leaving a
166
+ # guess from COLORFGBG that only a later switch would otherwise correct.
167
+ self.refresh()
158
168
  reader = self.reader if self.reader is not None else desktop_reader()
159
169
  pending = None
160
170
  try:
@@ -52,6 +52,8 @@ A **brief** explains one project as it is now: what it is, where it's at, how it
52
52
  | `plans` | links to open plans | | |
53
53
  | `sources` | session IDs, plus code paths or commit ranges | same | |
54
54
 
55
+ Properties are YAML, and one bad value breaks the whole block: Obsidian then shows it as raw red text instead of properties. Wrap every free-text value (`scope`, `tldr`, `next` and each `sources` item) in single quotes, doubling any `'` inside: `tldr: 'Checks the org''s code: once per ticket'`. Unquoted, a `: ` anywhere or a leading `` ` ``, `#`, `*`, `[`, `{`, `&`, `!`, `|`, `>`, `%` or `@` breaks it.
56
+
55
57
  ## Sections
56
58
 
57
59
  | Section | Answers | Budget |
@@ -6,7 +6,7 @@ config directory, which is never in a repository, so org names stay local.
6
6
 
7
7
  A **brief** explains a project as it is: what it is, where it's at, how it
8
8
  works, what's wrong, what's next. A **plan** is a proposed brief in the same
9
- format; with this extension on, `/plan` writes it into the vault. How both
9
+ format; `/plan --obsidian` writes it into the vault. How both
10
10
  are written lives in the vault's `Brief format.md`, which the model reads
11
11
  before writing, so the user can change the format without touching code.
12
12
 
@@ -23,9 +23,7 @@ tools. What this module adds is only:
23
23
  Obsidian reach the model without being retyped.
24
24
 
25
25
  Known limits: an edit the user makes mid-turn to a note the model then edits
26
- again is folded into the model's edit and not reported; and `plan_location`
27
- trusts the extensions_on preference, so a user file that overrides this
28
- extension still gets vault plans from this module.
26
+ again is folded into the model's edit and not reported.
29
27
  """
30
28
 
31
29
  import asyncio
@@ -34,6 +32,7 @@ import json
34
32
  import re
35
33
  import shlex
36
34
  import shutil
35
+ import tempfile
37
36
  import tomllib
38
37
  from dataclasses import dataclass
39
38
  from pathlib import Path
@@ -120,11 +119,22 @@ def load_orgs(path: Path | None = None) -> list[Org]:
120
119
  except (OSError, tomllib.TOMLDecodeError) as error:
121
120
  raise ValueError(f"{path}: {error}") from None
122
121
  default_sync = bool(data.get("auto_sync", False))
123
- vault_root = Path(str(data.get("vault_root", DEFAULT_VAULT_ROOT))).expanduser()
122
+ root_value = data.get("vault_root", DEFAULT_VAULT_ROOT)
123
+ if not isinstance(root_value, str) or not root_value.strip():
124
+ raise ValueError(f"{path}: vault_root must be a nonempty path string")
125
+ vault_root = Path(root_value).expanduser()
126
+ entries = data.get("org", [])
127
+ if not isinstance(entries, list):
128
+ raise ValueError(f"{path}: org must be an array of [[org]] tables")
124
129
  orgs = []
125
- for entry in data.get("org", []):
126
- if not isinstance(entry, dict) or not entry.get("name") or not entry.get("root"):
130
+ for entry in entries:
131
+ if not isinstance(entry, dict) or any(
132
+ not isinstance(entry.get(key), str) or not entry[key].strip()
133
+ for key in ("name", "root")
134
+ ):
127
135
  raise ValueError(f"{path}: every [[org]] needs a name and a root")
136
+ if "vault" in entry and (not isinstance(entry["vault"], str) or not entry["vault"].strip()):
137
+ raise ValueError(f"{path}: vault must be a nonempty path string")
128
138
  name = str(entry["name"])
129
139
  if safe_name(name) != name:
130
140
  raise ValueError(f"{path}: org name {name!r} must be a plain file name")
@@ -235,6 +245,43 @@ def edit_diff(path: Path, before: str, after: str) -> str:
235
245
  return "\n".join(lines)
236
246
 
237
247
 
248
+ def is_side(ctx) -> bool:
249
+ """Side questions and shared workers must not consume the main conversation's edits."""
250
+ from pcode.aside_guard import AsideGuard
251
+
252
+ if getattr(getattr(ctx, "agent", None), "name", None) == "worker":
253
+ return True
254
+ capabilities = getattr(ctx, "capabilities", None) or {}
255
+ return any(isinstance(c, AsideGuard) for c in capabilities.values())
256
+
257
+
258
+ class NoteFeedback:
259
+ """Snapshots shared by broad memory feedback and active-plan-only feedback."""
260
+
261
+ def __init__(self):
262
+ self.seen: dict[Path, str] = {}
263
+
264
+ def observe(self, path: Path) -> None:
265
+ try:
266
+ self.seen[path] = path.read_text(encoding="utf-8")
267
+ except OSError:
268
+ self.seen.pop(path, None)
269
+
270
+ def edits(self, only: Path | None = None) -> list[str]:
271
+ changed = []
272
+ for path, before in list(self.seen.items()):
273
+ if only is not None and path != only:
274
+ continue
275
+ try:
276
+ after = path.read_text(encoding="utf-8")
277
+ except OSError:
278
+ continue
279
+ if after != before:
280
+ self.seen[path] = after
281
+ changed.append(f"`{path}`:\n```diff\n{edit_diff(path, before, after)}\n```")
282
+ return changed
283
+
284
+
238
285
  # --- Vault setup ---------------------------------------------------------------------
239
286
 
240
287
 
@@ -302,24 +349,49 @@ def enabled() -> bool:
302
349
  return NAME in name_list("extensions_on") - name_list("extensions_off")
303
350
 
304
351
 
305
- def plan_location(workspace: Path) -> PlanLocation | None:
306
- """Where `/plan` writes when this extension is on and `workspace` is in an org."""
307
- if not enabled():
308
- return None
352
+ def plan_guidance(directory: Path) -> str:
353
+ """Guidance also works for a saved absolute marker without current org configuration."""
354
+ return (
355
+ f"- This plan is a proposed brief: re-read `{directory.parent / FORMAT_NOTE}` before "
356
+ "creating or revising it and follow its Plans section (type: plan, state: proposed, "
357
+ "cssclasses: [proposed], the PROPOSED banner). Link an existing project brief via "
358
+ f"`project:` if one in `{directory}` matches this workspace's `dirs`. "
359
+ "If none exists, write a standalone plan; do not create a project brief."
360
+ )
361
+
362
+
363
+ def plan_location(workspace: Path) -> PlanLocation:
364
+ """The vault for `/plan --obsidian`, whether or not this extension is on.
365
+
366
+ Raises ValueError with setup guidance when no writable org vault covers `workspace`.
367
+ """
309
368
  try:
310
369
  place = place_for(workspace)
311
- except ValueError:
312
- return None
313
- if place is None:
314
- return None
315
- vault = place.org.vault
370
+ if place is None:
371
+ raise ValueError(f"No configured org covers {workspace}")
372
+ vault = place.org.vault
373
+ # Planning needs neither the dashboard nor Obsidian settings or sync.
374
+ format_note = vault / FORMAT_NOTE
375
+ if (format_note.exists() or format_note.is_symlink()) and not format_note.is_file():
376
+ raise ValueError(f"{format_note} must be a file")
377
+ briefs = vault / BRIEFS
378
+ briefs.mkdir(parents=True, exist_ok=True)
379
+ # Existing directories and format notes don't prove new plans can be written.
380
+ with tempfile.NamedTemporaryFile(dir=briefs, prefix=".pcode-plan-") as probe:
381
+ probe.write(b"plan write probe\n")
382
+ probe.flush()
383
+ if not format_note.exists():
384
+ write_text(format_note, (FILES / FORMAT_NOTE).read_text(encoding="utf-8"))
385
+ except (OSError, ValueError) as error:
386
+ raise ValueError(
387
+ f"/plan --obsidian: {error}. Configure a matching [[org]] with name, root "
388
+ f"and a writable vault in {config_path()}, then retry (or use /plan --local)."
389
+ ) from None
316
390
  project = place.repo or (Path(place.dir).name if place.dir != "." else place.org.name)
317
391
  return PlanLocation(
318
392
  vault / BRIEFS,
319
393
  safe_name(project, 40) or place.org.name,
320
- f"- This plan is a proposed brief: read `{vault / FORMAT_NOTE}` first and follow its "
321
- "Plans section (type: plan, state: proposed, cssclasses: [proposed], the PROPOSED "
322
- "banner, `project:` linking the brief it changes, and only the sections that change).",
394
+ plan_guidance(vault / BRIEFS),
323
395
  )
324
396
 
325
397
 
@@ -410,7 +482,7 @@ def setup(pcode) -> None:
410
482
  current: dict = {"session": None}
411
483
  # Sync per session; None holds a choice made before the first message.
412
484
  sync: dict[str | None, bool] = {}
413
- seen: dict[Path, str] = {} # Note text as the model last read or wrote it.
485
+ feedback = NoteFeedback()
414
486
 
415
487
  def session_of(ctx) -> str | None:
416
488
  return base_conversation(getattr(ctx, "conversation_id", None)) or current["session"]
@@ -433,14 +505,6 @@ def setup(pcode) -> None:
433
505
  if session and state_path(session).parent.is_dir(): # Unsaved sessions have none.
434
506
  write_text(state_path(session), json.dumps({"sync": on}))
435
507
 
436
- def is_side(ctx) -> bool:
437
- from pcode.aside_guard import AsideGuard
438
-
439
- if getattr(getattr(ctx, "agent", None), "name", None) == "worker":
440
- return True
441
- capabilities = getattr(ctx, "capabilities", None) or {}
442
- return any(isinstance(c, AsideGuard) for c in capabilities.values())
443
-
444
508
  def in_vault(path: Path) -> bool:
445
509
  return path.suffix == ".md" and (path == vault or vault in path.parents)
446
510
 
@@ -450,7 +514,7 @@ def setup(pcode) -> None:
450
514
  if is_side(ctx) or not session or session == current["session"]:
451
515
  return
452
516
  current["session"] = session
453
- seen.clear() # Notes read in another conversation are not this one's to diff.
517
+ feedback.seen.clear() # Another conversation's notes are not this one's to diff.
454
518
  if None in sync:
455
519
  set_sync(sync.pop(None), session)
456
520
 
@@ -460,24 +524,9 @@ def setup(pcode) -> None:
460
524
  path = Path(str(args.get("path", ""))).expanduser()
461
525
  path = (path if path.is_absolute() else pcode.workspace / path).resolve()
462
526
  if in_vault(path):
463
- try:
464
- seen[path] = await asyncio.to_thread(path.read_text, encoding="utf-8")
465
- except OSError:
466
- seen.pop(path, None)
527
+ await asyncio.to_thread(feedback.observe, path)
467
528
  return result
468
529
 
469
- def edits() -> list[str]:
470
- changed = []
471
- for path, before in list(seen.items()):
472
- try:
473
- after = path.read_text(encoding="utf-8")
474
- except OSError:
475
- continue
476
- if after != before:
477
- seen[path] = after
478
- changed.append(f"`{path}`:\n```diff\n{edit_diff(path, before, after)}\n```")
479
- return changed
480
-
481
530
  @pcode.hooks.on.before_model_request
482
531
  async def remind(ctx, request_context):
483
532
  # Once per user message, persisted in history so the cached prefix holds.
@@ -494,7 +543,7 @@ def setup(pcode) -> None:
494
543
  ):
495
544
  return request_context
496
545
  lines = [SYNC] if sync_on(session_of(ctx)) else []
497
- changed = await asyncio.to_thread(edits)
546
+ changed = await asyncio.to_thread(feedback.edits)
498
547
  if changed:
499
548
  lines += [EDITED, *changed]
500
549
  if lines:
@@ -13,18 +13,22 @@ The model is trusted rather than fenced in: nothing blocks edits, the reminder
13
13
  just says what the user wants. The active plan lives in a marker file beside
14
14
  the plans, so it survives `/reload` (which re-imports this module) and restarts.
15
15
 
16
- With the `basic_memory` extension on and the workspace in one of its orgs, the
17
- plan is a note in that org's vault instead (`briefs/Plan — <project> — <title>.md`),
18
- in the brief format, and on approval the model marks it approved there. The
19
- marker then holds the note's absolute path rather than a slug.
16
+ `--obsidian` makes the plan a note in the workspace's org vault instead
17
+ (`briefs/Plan — <project> — <title>.md`, configured in `memory.toml`), whether or
18
+ not the `basic_memory` extension is on: where a plan lives is a per-plan choice,
19
+ not a memory setting. `--local` (the default) keeps it in `.pcode/plans`. The
20
+ marker holds a vault note's absolute path rather than a local slug. With memory
21
+ off, only the active note gets edit feedback and lifecycle updates.
20
22
  """
21
23
 
24
+ import asyncio
22
25
  import re
23
26
  from pathlib import Path
24
27
 
25
28
  from pydantic_ai import ModelRetry
26
29
  from pydantic_ai.messages import ModelRequest, ToolReturnPart, UserPromptPart
27
30
 
31
+ from pcode.conversation_ids import base_conversation
28
32
  from pcode.steering import run_request
29
33
 
30
34
  PLANS = ".pcode/plans"
@@ -49,11 +53,6 @@ Plan mode is on. The user wants to agree on a plan before any implementation.
49
53
  </plan-mode>"""
50
54
 
51
55
 
52
- def is_worker(ctx) -> bool:
53
- """A shared delegate runs this extension's hooks too; plan mode is the parent's."""
54
- return getattr(ctx.agent, "name", None) == "worker"
55
-
56
-
57
56
  def slugify(text: str) -> str:
58
57
  slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
59
58
  if len(slug) > 60: # Cut at a word boundary when there is one.
@@ -67,14 +66,39 @@ def topic_slug(request: str) -> str:
67
66
  return slugify(" ".join(request.split("\n", 1)[0].split()[:8]))
68
67
 
69
68
 
69
+ def parse_destination(argument: str) -> tuple[str | None, str]:
70
+ """Consume only leading flags, leaving the request's internal whitespace untouched."""
71
+ destination = None
72
+ argument = argument.strip()
73
+ while argument.startswith("-"):
74
+ flag, *rest = argument.split(maxsplit=1)
75
+ if flag not in ("--obsidian", "--local"):
76
+ raise ValueError(f"Unknown /plan flag {flag!r}; use --obsidian or --local.")
77
+ if destination is not None and destination != flag:
78
+ raise ValueError("/plan --obsidian and --local cannot be combined.")
79
+ destination = flag
80
+ argument = rest[0] if rest else ""
81
+ return destination, argument
82
+
83
+
70
84
  def setup(pcode) -> None:
71
85
  if pcode.is_worker:
72
86
  return
73
87
  plans = pcode.workspace / PLANS
74
88
  marker = plans / ACTIVE
75
- from pcode.extensions.basic_memory import plan_location
89
+ from pcode.extensions.basic_memory import (
90
+ EDITED,
91
+ NOTE_TOOLS,
92
+ NoteFeedback,
93
+ enabled,
94
+ is_side,
95
+ plan_guidance,
96
+ plan_location,
97
+ )
76
98
 
77
- location = plan_location(pcode.workspace) # None: plans stay in .pcode/plans
99
+ memory_on = enabled()
100
+ feedback = NoteFeedback()
101
+ session = None
78
102
 
79
103
  def active() -> str | None:
80
104
  try:
@@ -98,15 +122,13 @@ def setup(pcode) -> None:
98
122
  def latest() -> str | None:
99
123
  # Only local plans: a vault holds every repo's plans, so "the newest" could
100
124
  # be another project's.
101
- if location:
102
- return None
103
125
  files = (
104
126
  sorted(plans.glob("*.md"), key=lambda p: p.stat().st_mtime) if plans.is_dir() else []
105
127
  )
106
128
  return files[-1].stem if files else None
107
129
 
108
- def new_key(request: str) -> str:
109
- return str(location.path_for(request)) if location else topic_slug(request)
130
+ def new_key(request: str, destination) -> str:
131
+ return str(destination.path_for(request)) if destination else topic_slug(request)
110
132
 
111
133
  def start(slug: str) -> None:
112
134
  plans.mkdir(parents=True, exist_ok=True)
@@ -116,22 +138,42 @@ def setup(pcode) -> None:
116
138
  marker.write_text(slug + "\n")
117
139
 
118
140
  def plan_command(argument: str) -> str | None:
119
- argument = argument.strip()
141
+ flag, argument = parse_destination(argument)
120
142
  current = active()
121
- if argument == "off":
143
+ if flag is None and argument == "off":
122
144
  if current is None:
123
145
  pcode.ui.notify("Plan mode is already off.")
124
146
  return
125
147
  marker.unlink(missing_ok=True)
126
148
  pcode.ui.notify(f"Plan mode off. The plan stays in {relative(current)}.")
127
149
  return
128
- if argument == "status":
150
+ if flag is None and argument == "status":
129
151
  pcode.ui.notify(
130
152
  f"Plan mode on: {relative(current)}" if current else "Plan mode is off."
131
153
  )
132
154
  return
133
- slug = new_key(argument) if argument else (current or latest() or new_key("plan"))
155
+ destination = plan_location(pcode.workspace) if flag == "--obsidian" else None
156
+ compatible = current
157
+ if flag and current:
158
+ matches = (
159
+ Path(current).parent == destination.directory
160
+ if destination
161
+ else not is_vault(current)
162
+ )
163
+ if not matches:
164
+ compatible = None
165
+ slug = (
166
+ new_key(argument, destination)
167
+ if argument
168
+ else (
169
+ compatible or (latest() if flag is None else None) or new_key("plan", destination)
170
+ )
171
+ )
134
172
  start(slug)
173
+ if not memory_on and is_vault(slug):
174
+ path = path_of(slug).resolve()
175
+ if slug != current or path not in feedback.seen:
176
+ feedback.observe(path)
135
177
  exists = path_of(slug).exists()
136
178
  verb = "Resuming" if exists else "Planning in"
137
179
  ask = "Say" if argument else "Describe the change; say"
@@ -147,9 +189,11 @@ def setup(pcode) -> None:
147
189
  "/plan",
148
190
  "Plan before implementing; the model exits when you approve",
149
191
  plan_command,
150
- arguments=("off", "status"),
192
+ arguments=("off", "status", "--obsidian", "--local"),
151
193
  free_arguments=True, # anything else is the request to plan
152
194
  argument_descriptions={
195
+ "--obsidian": "Keep the plan in the org's Obsidian vault",
196
+ "--local": "Keep the plan in .pcode/plans (the default)",
153
197
  "off": "Leave plan mode (the plan file is kept)",
154
198
  "status": "Show whether plan mode is on and its file",
155
199
  },
@@ -174,24 +218,68 @@ def setup(pcode) -> None:
174
218
  if is_vault(current):
175
219
  result += (
176
220
  " First mark it approved, as the brief format says: `state: approved`, "
177
- "`cssclasses: [approved]`, the APPROVED banner, and ✅ on the brief's Open plans "
178
- "line. When the work ships, fold it into the brief and mark the plan implemented."
221
+ "`cssclasses: [approved]`, and the APPROVED banner."
179
222
  )
223
+ if memory_on:
224
+ result += (
225
+ " If an existing brief is linked, add ✅ on the brief's Open plans line "
226
+ "and, when the work ships, fold it into the brief. Mark the plan implemented "
227
+ "when shipped, even if it is standalone; do not create a brief implicitly."
228
+ )
229
+ else:
230
+ result += (
231
+ " When the work ships, mark the plan implemented. Project memory is off: "
232
+ "update only this plan, not a project brief; do not create or maintain one."
233
+ )
180
234
  return result
181
235
 
182
236
  @pcode.hooks.on.before_tool_execute
183
237
  async def workers_cannot_exit(ctx, *, call, tool_def, args):
184
- if tool_def.name == "exit_plan_mode" and is_worker(ctx):
238
+ if tool_def.name == "exit_plan_mode" and is_side(ctx):
185
239
  raise ModelRetry("Only the main agent can leave plan mode.")
186
240
  return args
187
241
 
242
+ if not memory_on:
243
+ current = active()
244
+ if current and is_vault(current):
245
+ feedback.observe(path_of(current).resolve())
246
+
247
+ @pcode.hooks.on.before_run
248
+ async def remember_session(ctx):
249
+ nonlocal session
250
+ incoming = base_conversation(getattr(ctx, "conversation_id", None))
251
+ if is_side(ctx) or not incoming or incoming == session:
252
+ return
253
+ if session is not None:
254
+ feedback.seen.clear()
255
+ current = active()
256
+ if current and is_vault(current):
257
+ await asyncio.to_thread(feedback.observe, path_of(current).resolve())
258
+ session = incoming
259
+
260
+ @pcode.hooks.on.after_tool_execute
261
+ async def note_seen(ctx, *, call, tool_def, args, result):
262
+ current = active()
263
+ if (
264
+ current
265
+ and is_vault(current)
266
+ and tool_def.name in NOTE_TOOLS
267
+ and not is_side(ctx)
268
+ and isinstance(args, dict)
269
+ ):
270
+ path = Path(str(args.get("path", ""))).expanduser()
271
+ path = (path if path.is_absolute() else pcode.workspace / path).resolve()
272
+ if path == path_of(current).resolve():
273
+ await asyncio.to_thread(feedback.observe, path)
274
+ return result
275
+
188
276
  @pcode.hooks.on.before_model_request
189
277
  async def remind(ctx, request_context):
190
278
  # Once per user turn, persisted in history so the prompt prefix stays
191
279
  # cached. A retried turn resends the same request, so skip one that
192
280
  # already carries the reminder or is mid-turn (holds tool results).
193
281
  current = active()
194
- if current is None or is_worker(ctx):
282
+ if current is None or is_side(ctx):
195
283
  return request_context
196
284
  request = run_request(ctx, request_context)
197
285
  parts = request.parts if isinstance(request, ModelRequest) else ()
@@ -201,6 +289,22 @@ def setup(pcode) -> None:
201
289
  and not any(isinstance(p, ToolReturnPart) for p in parts)
202
290
  and not any(str(p.content).startswith("<plan-mode>") for p in prompts)
203
291
  ):
204
- extra = f"\n{location.guidance}" if location and is_vault(current) else ""
292
+ extra = ""
293
+ if is_vault(current):
294
+ extra = "\n" + plan_guidance(path_of(current).parent)
295
+ if conversation := base_conversation(getattr(ctx, "conversation_id", None)):
296
+ # The Obsidian bridge plugin finds this conversation through it.
297
+ extra += (
298
+ f"\n- Keep `session: {conversation}` in the plan's front matter "
299
+ "(add it, or update it if it names another session)."
300
+ )
301
+ if not memory_on:
302
+ extra += (
303
+ "\n- Project memory is off. Update only this plan, not project briefs, "
304
+ "including on approval and completion."
305
+ )
306
+ changed = await asyncio.to_thread(feedback.edits, path_of(current).resolve())
307
+ if changed:
308
+ extra += "\n" + EDITED + "\n" + "\n\n".join(changed)
205
309
  parts.append(UserPromptPart(REMINDER.format(path=relative(current), extra=extra)))
206
310
  return request_context
@@ -200,10 +200,7 @@ def reference_fragment(text: str) -> str | None:
200
200
 
201
201
 
202
202
  class ReferenceLexer(Lexer):
203
- """Style file references in the editor so they read as paths, not prose.
204
-
205
- ``extra`` adds further ``(pattern, style)`` tokens, such as paste markers.
206
- """
203
+ """Style file references in the editor so they read as paths, not prose."""
207
204
 
208
205
  def __init__(self, extra: list[tuple[re.Pattern[str], str]] = ()) -> None:
209
206
  self.patterns = [(REFERENCE_PATTERN, "class:reference"), *extra]