@gajae-code/coding-agent 0.8.2 → 0.9.1

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 (214) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/types/append-only-mode.d.ts +31 -0
  3. package/dist/types/capability/rule.d.ts +8 -0
  4. package/dist/types/cli/fixture-report.d.ts +20 -0
  5. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  6. package/dist/types/cli/nofile-limit.d.ts +11 -0
  7. package/dist/types/cli/update-cli.d.ts +22 -0
  8. package/dist/types/config/settings-schema.d.ts +119 -1
  9. package/dist/types/discovery/index.d.ts +1 -0
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/export/ttsr.d.ts +14 -3
  12. package/dist/types/gjc-runtime/deep-interview-state.d.ts +5 -2
  13. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +7 -0
  14. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  15. package/dist/types/gjc-runtime/team-runtime.d.ts +26 -0
  16. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  17. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +119 -13
  19. package/dist/types/goals/runtime.d.ts +1 -0
  20. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  22. package/dist/types/modes/components/status-line.d.ts +9 -0
  23. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  26. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  27. package/dist/types/modes/interactive-mode.d.ts +4 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  29. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  30. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  31. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  32. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  33. package/dist/types/modes/types.d.ts +3 -0
  34. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  35. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  36. package/dist/types/notifications/config-commands.d.ts +9 -0
  37. package/dist/types/notifications/config.d.ts +22 -0
  38. package/dist/types/notifications/index.d.ts +2 -0
  39. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  40. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  41. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  42. package/dist/types/notifications/rich-draft.d.ts +68 -0
  43. package/dist/types/notifications/rich-render.d.ts +90 -0
  44. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  45. package/dist/types/notifications/telegram-daemon.d.ts +30 -0
  46. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  47. package/dist/types/notifications/threaded-render.d.ts +14 -0
  48. package/dist/types/sdk.d.ts +3 -0
  49. package/dist/types/session/agent-session.d.ts +43 -1
  50. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  51. package/dist/types/session/messages.d.ts +4 -0
  52. package/dist/types/session/session-manager.d.ts +17 -1
  53. package/dist/types/skill-state/active-state.d.ts +18 -1
  54. package/dist/types/system-prompt.d.ts +18 -2
  55. package/dist/types/task/executor.d.ts +1 -0
  56. package/dist/types/task/fork-context-budget.d.ts +7 -0
  57. package/dist/types/task/token-log.d.ts +30 -0
  58. package/dist/types/task/token-log.test.d.ts +1 -0
  59. package/dist/types/tools/bisect.d.ts +81 -0
  60. package/dist/types/tools/index.d.ts +1 -0
  61. package/dist/types/tools/search.d.ts +2 -2
  62. package/dist/types/utils/file-mentions.d.ts +14 -0
  63. package/dist/types/utils/git.d.ts +32 -0
  64. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  65. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  66. package/package.json +8 -11
  67. package/src/append-only-mode.ts +58 -0
  68. package/src/autoresearch/prompt.md +1 -1
  69. package/src/autoresearch/tools/log-experiment.ts +1 -1
  70. package/src/capability/rule.ts +9 -0
  71. package/src/cli/config-cli.ts +27 -3
  72. package/src/cli/fixture-report.test.ts +230 -0
  73. package/src/cli/fixture-report.ts +171 -0
  74. package/src/cli/nofile-limit.ts +48 -0
  75. package/src/cli/notify-cli.ts +11 -9
  76. package/src/cli/update-cli.ts +85 -9
  77. package/src/cli.ts +26 -0
  78. package/src/commands/contribution-prep.ts +1 -1
  79. package/src/config/keybindings.ts +1 -1
  80. package/src/config/settings-schema.ts +76 -1
  81. package/src/defaults/gjc/skills/deep-interview/SKILL.md +12 -13
  82. package/src/defaults/gjc/skills/ralplan/SKILL.md +30 -93
  83. package/src/defaults/gjc/skills/team/SKILL.md +16 -13
  84. package/src/defaults/gjc/skills/ultragoal/SKILL.md +59 -4
  85. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  86. package/src/defaults/gjc-defaults.ts +9 -0
  87. package/src/discovery/helpers.ts +10 -0
  88. package/src/discovery/index.ts +1 -0
  89. package/src/export/html/template.generated.ts +1 -1
  90. package/src/export/html/template.js +60 -2
  91. package/src/export/ttsr.ts +96 -18
  92. package/src/extensibility/extensions/runner.ts +181 -219
  93. package/src/extensibility/gjc-plugins/runtime-adapters.ts +156 -44
  94. package/src/gjc-runtime/deep-interview-state.ts +31 -2
  95. package/src/gjc-runtime/launch-tmux.ts +6 -0
  96. package/src/gjc-runtime/session-state-sidecar.ts +208 -13
  97. package/src/gjc-runtime/state-writer.ts +15 -0
  98. package/src/gjc-runtime/team-runtime.ts +175 -19
  99. package/src/gjc-runtime/tmux-common.ts +1 -0
  100. package/src/gjc-runtime/ultragoal-guard.ts +55 -66
  101. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +315 -0
  102. package/src/gjc-runtime/ultragoal-runtime.ts +1602 -143
  103. package/src/gjc-runtime/workflow-manifest.generated.json +113 -2
  104. package/src/gjc-runtime/workflow-manifest.ts +74 -1
  105. package/src/goals/runtime.ts +10 -2
  106. package/src/hindsight/backend.ts +1 -3
  107. package/src/hooks/native-skill-hook.ts +95 -4
  108. package/src/internal-urls/docs-index.generated.ts +18 -9
  109. package/src/lsp/client.ts +1 -0
  110. package/src/main.ts +43 -2
  111. package/src/memories/index.ts +12 -1
  112. package/src/modes/components/assistant-message.ts +241 -82
  113. package/src/modes/components/custom-editor.ts +1 -0
  114. package/src/modes/components/footer.ts +7 -2
  115. package/src/modes/components/status-line.ts +167 -54
  116. package/src/modes/components/thinking-selector.ts +10 -8
  117. package/src/modes/components/tool-execution.ts +44 -19
  118. package/src/modes/controllers/command-controller.ts +39 -2
  119. package/src/modes/controllers/event-controller.ts +77 -49
  120. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  121. package/src/modes/controllers/input-controller.ts +13 -18
  122. package/src/modes/controllers/selector-controller.ts +41 -0
  123. package/src/modes/interactive-mode.ts +14 -1
  124. package/src/modes/rpc/rpc-mode.ts +32 -12
  125. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  126. package/src/modes/rpc/rpc-types.ts +9 -0
  127. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  128. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  129. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  130. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  131. package/src/modes/types.ts +3 -0
  132. package/src/modes/utils/injected-user-submission.ts +94 -0
  133. package/src/modes/utils/ui-helpers.ts +17 -4
  134. package/src/notifications/config-commands.ts +20 -0
  135. package/src/notifications/config.ts +43 -4
  136. package/src/notifications/index.ts +105 -9
  137. package/src/notifications/lifecycle-commands.ts +37 -16
  138. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  139. package/src/notifications/reply-sent-store.ts +134 -0
  140. package/src/notifications/rich-draft.ts +107 -0
  141. package/src/notifications/rich-render.ts +142 -0
  142. package/src/notifications/telegram-daemon-cli.ts +54 -7
  143. package/src/notifications/telegram-daemon-control.ts +2 -2
  144. package/src/notifications/telegram-daemon.ts +390 -56
  145. package/src/notifications/telegram-reference.ts +17 -0
  146. package/src/notifications/threaded-render.ts +45 -2
  147. package/src/prompts/agents/architect.md +19 -10
  148. package/src/prompts/agents/critic.md +16 -18
  149. package/src/prompts/agents/executor.md +14 -11
  150. package/src/prompts/agents/planner.md +18 -13
  151. package/src/prompts/ci-green-request.md +3 -8
  152. package/src/prompts/goals/goal-continuation.md +0 -5
  153. package/src/prompts/goals/goal-mode-active.md +0 -5
  154. package/src/prompts/system/agent-creation-architect.md +2 -2
  155. package/src/prompts/system/plan-mode-active.md +1 -1
  156. package/src/prompts/system/plan-mode-subagent.md +3 -3
  157. package/src/prompts/system/project-prompt.md +0 -13
  158. package/src/prompts/system/subagent-system-prompt.md +3 -2
  159. package/src/prompts/system/system-prompt.md +26 -19
  160. package/src/prompts/system/volatile-project-context.md +10 -0
  161. package/src/prompts/system/web-search.md +4 -7
  162. package/src/prompts/tools/apply-patch.md +6 -0
  163. package/src/prompts/tools/ast-edit.md +1 -0
  164. package/src/prompts/tools/bash.md +1 -1
  165. package/src/prompts/tools/bisect.md +22 -0
  166. package/src/prompts/tools/browser.md +1 -1
  167. package/src/prompts/tools/cron.md +0 -2
  168. package/src/prompts/tools/eval.md +2 -2
  169. package/src/prompts/tools/find.md +1 -1
  170. package/src/prompts/tools/github.md +6 -6
  171. package/src/prompts/tools/goal.md +1 -3
  172. package/src/prompts/tools/image-gen.md +12 -5
  173. package/src/prompts/tools/irc.md +5 -4
  174. package/src/prompts/tools/job.md +2 -2
  175. package/src/prompts/tools/lsp.md +1 -1
  176. package/src/prompts/tools/monitor.md +2 -2
  177. package/src/prompts/tools/patch.md +2 -2
  178. package/src/prompts/tools/replace.md +2 -21
  179. package/src/prompts/tools/rewind.md +1 -1
  180. package/src/prompts/tools/search-tool-bm25.md +6 -11
  181. package/src/prompts/tools/search.md +1 -1
  182. package/src/prompts/tools/skill.md +3 -2
  183. package/src/prompts/tools/ssh.md +2 -1
  184. package/src/prompts/tools/subagent.md +2 -2
  185. package/src/prompts/tools/task.md +4 -5
  186. package/src/prompts/tools/todo-write.md +1 -1
  187. package/src/prompts/tools/vim.md +1 -0
  188. package/src/prompts/tools/web-search.md +2 -0
  189. package/src/prompts/tools/write.md +8 -2
  190. package/src/sdk.ts +68 -30
  191. package/src/session/agent-session.ts +734 -107
  192. package/src/session/file-mention-pruning.ts +64 -0
  193. package/src/session/messages.ts +4 -0
  194. package/src/session/session-manager.ts +59 -2
  195. package/src/skill-state/active-state.ts +141 -5
  196. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  197. package/src/slash-commands/builtin-registry.ts +143 -1
  198. package/src/system-prompt.ts +57 -16
  199. package/src/task/executor.ts +125 -20
  200. package/src/task/fork-context-advisory.ts +2 -7
  201. package/src/task/fork-context-budget.ts +9 -0
  202. package/src/task/index.ts +4 -3
  203. package/src/task/output-manager.ts +4 -2
  204. package/src/task/token-log.test.ts +188 -0
  205. package/src/task/token-log.ts +179 -0
  206. package/src/tools/bisect.ts +367 -0
  207. package/src/tools/index.ts +3 -0
  208. package/src/tools/output-meta.ts +116 -4
  209. package/src/tools/search.ts +8 -2
  210. package/src/tools/skill.ts +15 -0
  211. package/src/utils/file-mentions.ts +39 -7
  212. package/src/utils/git.ts +73 -0
  213. package/src/utils/pasted-image-path.ts +170 -0
  214. package/src/web/scrapers/youtube.ts +24 -12
@@ -12,6 +12,9 @@ import { randomUUID } from "node:crypto";
12
12
  import type { AgentSessionEvent } from "../../../session/agent-session";
13
13
  import {
14
14
  AGENT_WIRE_PROTOCOL_VERSION,
15
+ type AgentWireCompactAssistantMessageEvent,
16
+ type AgentWireCompactEventFrame,
17
+ type AgentWireCompactMessageUpdatePayload,
15
18
  type AgentWireEventFrame,
16
19
  type AgentWireEventPayload,
17
20
  type AgentWireEventType,
@@ -19,6 +22,26 @@ import {
19
22
  type AgentWireFrameType,
20
23
  } from "./event-contract";
21
24
 
25
+ export const AGENT_WIRE_COMPACT_MESSAGE_UPDATE_CHECKPOINT_INTERVAL = 32;
26
+
27
+ function recordObject(value: unknown): Record<string, unknown> | undefined {
28
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
29
+ }
30
+
31
+ function messageIdOf(message: unknown): string | null {
32
+ const id = recordObject(message)?.id;
33
+ return typeof id === "string" ? id : null;
34
+ }
35
+
36
+ function contentIndexOf(assistantMessageEvent: unknown): number | null {
37
+ const contentIndex = recordObject(assistantMessageEvent)?.contentIndex;
38
+ return typeof contentIndex === "number" && Number.isInteger(contentIndex) ? contentIndex : null;
39
+ }
40
+
41
+ function snapshotMessage<T>(message: T): T {
42
+ return structuredClone(message);
43
+ }
44
+
22
45
  function assertNever(value: never): never {
23
46
  throw new Error(`Unhandled AgentSessionEvent variant: ${JSON.stringify(value)}`);
24
47
  }
@@ -123,6 +146,65 @@ export function toAgentWireEventFrame(
123
146
  export function toAgentWireEventPayload(event: AgentSessionEvent): AgentWireEventPayload {
124
147
  return { event_type: agentSessionEventType(event), event };
125
148
  }
149
+ export function sanitizeAssistantMessageEventForCompact(
150
+ assistantMessageEvent: Extract<AgentSessionEvent, { type: "message_update" }>["assistantMessageEvent"],
151
+ ): AgentWireCompactAssistantMessageEvent {
152
+ const {
153
+ partial: _partial,
154
+ message: _message,
155
+ error: _error,
156
+ ...deltaOnly
157
+ } = assistantMessageEvent as Record<string, unknown>;
158
+ return deltaOnly as AgentWireCompactAssistantMessageEvent;
159
+ }
160
+
161
+ export interface AgentWireCompactMessageUpdateOptions {
162
+ checkpointInterval?: number;
163
+ }
164
+
165
+ /**
166
+ * Stateful compact event serializer for opt-in RPC clients.
167
+ *
168
+ * Only `message_update` changes shape: it carries the immutable provider delta
169
+ * plus message/content identifiers, with periodic full-message checkpoints that
170
+ * are cloned synchronously before the caller returns to the event loop. All other
171
+ * event variants remain canonical full event frames so legacy bridge/RPC behavior
172
+ * and terminal `message_end` reconstruction are unchanged.
173
+ */
174
+ export class AgentWireCompactEventEncoder {
175
+ readonly #sequencer: AgentWireFrameSequencer;
176
+ readonly #checkpointInterval: number;
177
+ #messageUpdateCount = 0;
178
+
179
+ constructor(sequencer: AgentWireFrameSequencer, options: AgentWireCompactMessageUpdateOptions = {}) {
180
+ this.#sequencer = sequencer;
181
+ this.#checkpointInterval = options.checkpointInterval ?? AGENT_WIRE_COMPACT_MESSAGE_UPDATE_CHECKPOINT_INTERVAL;
182
+ }
183
+
184
+ frame(event: AgentSessionEvent): AgentWireCompactEventFrame {
185
+ if (event.type !== "message_update") return toAgentWireEventFrame(event, this.#sequencer);
186
+ this.#messageUpdateCount += 1;
187
+ const payload: AgentWireCompactMessageUpdatePayload = {
188
+ event_type: "message_update",
189
+ compact: true,
190
+ message_id: messageIdOf(event.message),
191
+ content_index: contentIndexOf(event.assistantMessageEvent),
192
+ assistantMessageEvent: sanitizeAssistantMessageEventForCompact(event.assistantMessageEvent),
193
+ };
194
+ if (this.#checkpointInterval > 0 && this.#messageUpdateCount % this.#checkpointInterval === 0) {
195
+ payload.checkpoint_message = snapshotMessage(event.message);
196
+ payload.checkpoint_reason = "periodic";
197
+ }
198
+ return this.#sequencer.next("event", payload);
199
+ }
200
+ }
201
+
202
+ export function toAgentWireCompactEventFrame(
203
+ event: AgentSessionEvent,
204
+ encoder: AgentWireCompactEventEncoder,
205
+ ): AgentWireCompactEventFrame {
206
+ return encoder.frame(event);
207
+ }
126
208
 
127
209
  /** Back-compat alias for {@link toAgentWireEventFrame}. */
128
210
  export const toBridgeEventFrame = toAgentWireEventFrame;
@@ -47,6 +47,7 @@ const RPC_COMMAND_SCOPE_REGISTRY: Record<RpcCommandType, BridgeCommandScope> = {
47
47
  set_host_tools: "host_tools",
48
48
  set_host_uri_schemes: "host_uri",
49
49
  get_pending_workflow_gates: "message:read",
50
+ set_capabilities: "control",
50
51
  set_model: "model",
51
52
  cycle_model: "model",
52
53
  get_available_models: "model",
@@ -116,6 +116,7 @@ export interface InteractiveModeContext {
116
116
  onInputCallback?: (input: SubmittedUserInput) => void;
117
117
  optimisticUserMessageSignature: string | undefined;
118
118
  locallySubmittedUserSignatures: Set<string>;
119
+ optimisticInjectedSignatures: Map<string, number>;
119
120
  lastSigintTime: number;
120
121
  lastEscapeTime: number;
121
122
  lastComposerClearEscapeTime: number;
@@ -216,6 +217,7 @@ export interface InteractiveModeContext {
216
217
  handleDumpCommand(): void;
217
218
  handleDebugTranscriptCommand(): Promise<void>;
218
219
  handleClearCommand(): Promise<void>;
220
+ handleContextClearCommand(): Promise<void>;
219
221
  handleDropCommand(): Promise<void>;
220
222
  handleForkCommand(): Promise<void>;
221
223
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
@@ -243,6 +245,7 @@ export interface InteractiveModeContext {
243
245
  showExtensionsDashboard(): void;
244
246
  showAgentsDashboard(): void;
245
247
  showModelSelector(options?: { temporaryOnly?: boolean }): void;
248
+ showEffortSelector(): void;
246
249
  showProviderOnboarding(): void;
247
250
  showPluginSelector(mode?: "install" | "uninstall"): void;
248
251
  showUserMessageSelector(): void;
@@ -0,0 +1,94 @@
1
+ import type { ImageContent, TextContent } from "@gajae-code/ai";
2
+ import type { InteractiveModeContext } from "../types";
3
+
4
+ /**
5
+ * Normalize the content passed to an extension `sendUserMessage` call into a
6
+ * plain text string plus its image attachments. Mirrors the normalization in
7
+ * `AgentSession.sendUserMessage` (text parts joined with "\n") so the resulting
8
+ * text matches the eventual user `message_start` payload.
9
+ */
10
+ export function normalizeInjectedUserContent(content: string | (TextContent | ImageContent)[]): {
11
+ text: string;
12
+ images: ImageContent[];
13
+ imageCount: number;
14
+ } {
15
+ if (typeof content === "string") {
16
+ return { text: content, images: [], imageCount: 0 };
17
+ }
18
+ const textParts: string[] = [];
19
+ const images: ImageContent[] = [];
20
+ for (const part of content) {
21
+ if (part.type === "text") textParts.push(part.text);
22
+ else images.push(part);
23
+ }
24
+ const text = textParts.join("\n");
25
+ return { text, images, imageCount: images.length };
26
+ }
27
+
28
+ /**
29
+ * Record a remotely/programmatically injected user message (e.g. Telegram
30
+ * inbound routed through the extension API) into the interactive TUI, so it is
31
+ * captured in prompt history and shown immediately instead of only appearing
32
+ * once the eventual `message_start` event lands.
33
+ *
34
+ * Local TUI submissions never reach this path (they go through
35
+ * `session.prompt(...)` / `startPendingSubmission`), so this cannot double-add
36
+ * local prompt history.
37
+ *
38
+ * - Always adds the injected text to editor prompt history.
39
+ * - Idle injections optimistically render the user message and record a pending
40
+ * injected optimistic signature (a counting Map, so multiple idle injections
41
+ * before the first `message_start` do not clobber each other); the later user
42
+ * `message_start` consumes one count and skips both the duplicate chat add and
43
+ * the defensive editor clear (so a locally typed draft is preserved).
44
+ * - Busy/queued injections refresh the pending-message display, which the
45
+ * caller has already populated by invoking `session.sendUserMessage(...)`
46
+ * before this helper.
47
+ *
48
+ * This helper never clears the editor text.
49
+ */
50
+ export function applyInjectedUserSubmission(
51
+ ctx: InteractiveModeContext,
52
+ input: { content: string | (TextContent | ImageContent)[]; queued: boolean },
53
+ ): void {
54
+ const { text, images, imageCount } = normalizeInjectedUserContent(input.content);
55
+ ctx.editor.addToHistory(text);
56
+
57
+ if (input.queued) {
58
+ ctx.updatePendingMessagesDisplay();
59
+ ctx.ui.requestRender();
60
+ return;
61
+ }
62
+
63
+ incrementInjectedOptimisticSignature(ctx, `${text}\u0000${imageCount}`);
64
+ ctx.addMessageToChat({
65
+ role: "user",
66
+ content: [{ type: "text", text }, ...images],
67
+ attribution: "user",
68
+ timestamp: Date.now(),
69
+ });
70
+ ctx.ui.requestRender();
71
+ }
72
+
73
+ /**
74
+ * Record one pending optimistic render for an injected user message.
75
+ *
76
+ * Injected sends are fire-and-forget and multiple idle injections can be
77
+ * rendered before the first `message_start` arrives, so a counting Map (not a
78
+ * single slot) tracks how many optimistic renders are outstanding per signature.
79
+ */
80
+ export function incrementInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): void {
81
+ ctx.optimisticInjectedSignatures.set(signature, (ctx.optimisticInjectedSignatures.get(signature) ?? 0) + 1);
82
+ }
83
+
84
+ /**
85
+ * Consume one pending injected optimistic render for `signature`. Decrements the
86
+ * count (deleting the key at zero) and returns whether one was outstanding.
87
+ */
88
+ export function consumeInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): boolean {
89
+ const count = ctx.optimisticInjectedSignatures.get(signature) ?? 0;
90
+ if (count <= 0) return false;
91
+ if (count === 1) ctx.optimisticInjectedSignatures.delete(signature);
92
+ else ctx.optimisticInjectedSignatures.set(signature, count - 1);
93
+ return true;
94
+ }
@@ -36,6 +36,19 @@ interface RenderInitialMessagesOptions {
36
36
  preserveExistingChat?: boolean;
37
37
  }
38
38
 
39
+ export function argsWithPartialJson(args: unknown, partialJson: unknown): unknown {
40
+ if (typeof partialJson !== "string" || !args || typeof args !== "object" || Array.isArray(args)) return args;
41
+ // Non-enumerable so the transient streaming buffer reaches renderers via direct
42
+ // property read but never serializes into persisted/wire message content.
43
+ Object.defineProperty(args, "__partialJson", {
44
+ value: partialJson,
45
+ enumerable: false,
46
+ configurable: true,
47
+ writable: true,
48
+ });
49
+ return args;
50
+ }
51
+
39
52
  type QueuedMessages = {
40
53
  steering: string[];
41
54
  followUp: string[];
@@ -392,10 +405,10 @@ export class UiHelpers {
392
405
 
393
406
  readGroup = null;
394
407
  const tool = this.ctx.session.getToolByName(content.name);
395
- const renderArgs =
396
- "partialJson" in content
397
- ? { ...content.arguments, __partialJson: content.partialJson }
398
- : content.arguments;
408
+ const renderArgs = argsWithPartialJson(
409
+ content.arguments,
410
+ "partialJson" in content ? content.partialJson : undefined,
411
+ );
399
412
  const component = new ToolExecutionComponent(
400
413
  content.name,
401
414
  renderArgs,
@@ -48,3 +48,23 @@ export function parseInThreadConfigCommand(text: string): ConfigCommandChange |
48
48
  return undefined;
49
49
  }
50
50
  }
51
+
52
+ /**
53
+ * Parse a `/rich on|off` toggle. Returns `true`/`false` for a recognised
54
+ * on/off argument, or `undefined` otherwise (not a `/rich` command, or `/rich`
55
+ * with a missing/invalid argument). This is intentionally SEPARATE from
56
+ * `parseInThreadConfigCommand`: `/verbose`/`/redact` are producer/session config
57
+ * forwarded over the WS, whereas rich is Telegram-daemon delivery policy handled
58
+ * daemon-locally, so it never becomes a `config_command` frame or a user turn.
59
+ */
60
+ export function parseRichToggleCommand(text: string): boolean | undefined {
61
+ const trimmed = text.trim();
62
+ if (!trimmed.startsWith("/")) return undefined;
63
+ const [rawCommand, ...rest] = trimmed.slice(1).split(/\s+/);
64
+ // Accept the "/rich@botname" form Telegram appends in group chats.
65
+ if (rawCommand?.toLowerCase().split("@")[0] !== "rich") return undefined;
66
+ const arg = rest[0]?.toLowerCase();
67
+ if (arg === "on" || arg === "true" || arg === "1") return true;
68
+ if (arg === "off" || arg === "false" || arg === "0") return false;
69
+ return undefined;
70
+ }
@@ -16,6 +16,12 @@ export interface NotificationConfig {
16
16
  redact: boolean;
17
17
  verbosity: "lean" | "verbose";
18
18
  idleTimeoutMs: number;
19
+ rich: {
20
+ enabled: boolean;
21
+ };
22
+ richDraft: {
23
+ enabled: boolean;
24
+ };
19
25
  }
20
26
 
21
27
  /** Read typed config from Settings. */
@@ -35,19 +41,50 @@ export function getNotificationConfig(settings: Settings): NotificationConfig {
35
41
  redact: settings.get("notifications.redact"),
36
42
  verbosity: settings.get("notifications.verbosity") === "verbose" ? "verbose" : "lean",
37
43
  idleTimeoutMs: settings.get("notifications.daemon.idleTimeoutMs"),
44
+ rich: {
45
+ enabled: settings.get("notifications.telegram.rich.enabled"),
46
+ },
47
+ richDraft: {
48
+ enabled: settings.get("notifications.telegram.richDraft.enabled"),
49
+ },
38
50
  };
39
51
  }
40
52
 
53
+ export function hasNonBlankValue(value: string | undefined): boolean {
54
+ return typeof value === "string" && value.trim().length > 0;
55
+ }
56
+
57
+ /** Is Telegram configured with usable non-blank boundary credentials? */
58
+ export function isTelegramConfigured(
59
+ cfg: NotificationConfig,
60
+ ): cfg is NotificationConfig & { botToken: string; chatId: string } {
61
+ return cfg.enabled && hasNonBlankValue(cfg.botToken) && hasNonBlankValue(cfg.chatId);
62
+ }
63
+
41
64
  /** Is global config sufficient for auto-on (enabled + at least one configured adapter)? */
42
65
  export function isGloballyConfigured(cfg: NotificationConfig): boolean {
43
66
  return (
44
67
  cfg.enabled &&
45
- ((Boolean(cfg.botToken) && Boolean(cfg.chatId)) ||
46
- (Boolean(cfg.discord.botToken) && Boolean(cfg.discord.channelId)) ||
47
- (Boolean(cfg.slack.botToken) && Boolean(cfg.slack.channelId)))
68
+ (isTelegramConfigured(cfg) ||
69
+ (hasNonBlankValue(cfg.discord.botToken) && hasNonBlankValue(cfg.discord.channelId)) ||
70
+ (hasNonBlankValue(cfg.slack.botToken) && hasNonBlankValue(cfg.slack.channelId)))
48
71
  );
49
72
  }
50
73
 
74
+ /**
75
+ * Per-run opt-out for completion notifications, honored before settings lookups.
76
+ *
77
+ * `GJC_NOTIFY=off` (also `0` / `false`, case-insensitive) suppresses the
78
+ * completion notification surface for this process only. `config.yml` is
79
+ * untouched and child processes inherit the env var, which lets non-interactive
80
+ * fleet runs (`gjc -p --no-session`) stay silent even when a user-level/global
81
+ * completion notification configuration is enabled.
82
+ */
83
+ export function completionNotifyDisabledByEnv(env: NodeJS.ProcessEnv): boolean {
84
+ const v = env.GJC_NOTIFY?.trim().toLowerCase();
85
+ return v === "off" || v === "0" || v === "false";
86
+ }
87
+
51
88
  /** Resolve whether the notifications extension should be registered at SDK startup. */
52
89
  export function shouldRegisterNotificationsExtension(input: {
53
90
  env: NodeJS.ProcessEnv;
@@ -60,6 +97,7 @@ export function shouldRegisterNotificationsExtension(input: {
60
97
  currentAgentType?: string;
61
98
  }): boolean {
62
99
  if ((input.taskDepth ?? 0) > 0 || input.parentTaskPrefix || input.currentAgentType) return false;
100
+ if (completionNotifyDisabledByEnv(input.env)) return false;
63
101
  if (input.env.GJC_NOTIFICATIONS === "0") return false;
64
102
  if (input.env.GJC_NOTIFICATIONS === "1" || input.env.GJC_NOTIFICATIONS_TOKEN) return true;
65
103
  return input.cfg ? isGloballyConfigured(input.cfg) : false;
@@ -88,6 +126,7 @@ export function isSessionNotificationsEnabled(input: {
88
126
  /** Mask a bot token for display: first 4 chars + "…" + "(len N)"; "(unset)" when undefined/empty. Never reveal full token. */
89
127
  export function maskToken(token: string | undefined): string {
90
128
  if (!token) return "(unset)";
129
+ if (token.length <= 4) return `…(len ${token.length})`;
91
130
  return `${token.slice(0, 4)}…(len ${token.length})`;
92
131
  }
93
132
 
@@ -131,6 +170,6 @@ export function buildRedactedAction(
131
170
  // Asks stay fully readable/answerable even under redaction.
132
171
  if (action.kind === "ask") return action;
133
172
 
134
- const { summary: _summary, question: _question, ...base } = action;
173
+ const { summary: _summary, question: _question, options: _options, ...base } = action;
135
174
  return base;
136
175
  }
@@ -33,8 +33,8 @@ import { registerAskAnswerSource } from "../tools/ask-answer-registry";
33
33
  import { registerTelegramFileSink } from "./attachment-registry";
34
34
  import {
35
35
  getNotificationConfig,
36
- isGloballyConfigured,
37
36
  isSessionNotificationsEnabled,
37
+ isTelegramConfigured,
38
38
  type NotificationConfig,
39
39
  sessionTag,
40
40
  } from "./config";
@@ -90,6 +90,8 @@ export interface SessionCreateFrame {
90
90
  target: SessionCreateTarget;
91
91
  /** Reference to the daemon-written, once-consumed startup-prompt file. */
92
92
  startupPromptRef?: string;
93
+ /** Model profile preset to activate for the spawned session (--mpreset). */
94
+ modelPreset?: string;
93
95
  }
94
96
 
95
97
  /** Close (hard-kill, history preserved) a session. */
@@ -350,6 +352,15 @@ interface SessionRuntime {
350
352
  /** Assistant text already flushed before an ask this turn (turn-scoped dedupe
351
353
  * so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
352
354
  preAskFlushedText?: string;
355
+ /** Live streaming: opt-in flag, monotonic per-turn ref, and emit throttle state. */
356
+ stream: boolean;
357
+ turnSeq?: number;
358
+ liveRef?: string;
359
+ lastLiveAt?: number;
360
+ lastLiveText?: string;
361
+ /** True between turn_end and the next turn_start: drops late async message_update
362
+ * frames so a stale live edit can never be emitted after the finalized turn. */
363
+ turnClosed?: boolean;
353
364
  /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
354
365
  cancelPostmortemCleanup: () => void;
355
366
  }
@@ -377,12 +388,41 @@ const defaultConfig: NotificationConfig = {
377
388
  redact: false,
378
389
  verbosity: "lean",
379
390
  idleTimeoutMs: 60_000,
391
+ rich: { enabled: true },
392
+ richDraft: { enabled: false },
380
393
  };
381
394
 
382
395
  export function notificationsEnabled(): boolean {
383
396
  return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
384
397
  }
385
398
 
399
+ // Live streaming (opt-in): emit throttled non-finalized `turn_stream` frames as
400
+ // the assistant message streams so remote clients can edit ONE message live. The
401
+ // finalized frame (turn_end) carries the same messageRef and stays authoritative,
402
+ // so a dropped live frame self-heals. Off unless GJC_NOTIFICATIONS_STREAM=1.
403
+ function streamingEnabled(): boolean {
404
+ return process.env.GJC_NOTIFICATIONS_STREAM === "1";
405
+ }
406
+ function streamIntervalMs(): number {
407
+ return Math.max(200, Number(process.env.GJC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
408
+ }
409
+ // Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
410
+ // the pre-ask capture). Default 3500 keeps the mirror a glanceable per-turn
411
+ // summary; a client that splits long messages (the Telegram daemon does so via
412
+ // splitTelegramHtml, scheduling each chunk through the shared rate-limit pool so
413
+ // the fan-out never bypasses the per-chat limit) can raise it with
414
+ // GJC_NOTIFICATIONS_TURN_MAX to deliver full turns. The value is clamped to a
415
+ // finite [280, TURN_TEXT_MAX_CEILING] range: a non-finite or non-positive env
416
+ // (unset, NaN, Infinity, <= 0) falls back to the default, so the cap can never
417
+ // be unbounded. Live frames are intentionally NOT raised — they stay one
418
+ // editable preview message rather than fanning a long in-progress turn across
419
+ // sends.
420
+ const TURN_TEXT_MAX_CEILING = 40_000;
421
+ function turnTextMax(): number {
422
+ const raw = Number(process.env.GJC_NOTIFICATIONS_TURN_MAX);
423
+ if (!Number.isFinite(raw) || raw <= 0) return 3500;
424
+ return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
425
+ }
386
426
  function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
387
427
  if (settingsOverride)
388
428
  return { settings: settingsOverride, cfg: getNotificationConfig(settingsOverride), settingsAvailable: true };
@@ -708,6 +748,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
708
748
  cancelPostmortemCleanup: () => {},
709
749
  redact,
710
750
  verbosity,
751
+ stream: streamingEnabled(),
711
752
  sessionTag: tag,
712
753
  busy: false,
713
754
  pendingInbound: new Set<number>(),
@@ -724,7 +765,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
724
765
  });
725
766
  logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
726
767
 
727
- if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
768
+ if (settingsAvailable && settings && isTelegramConfigured(cfg)) {
728
769
  try {
729
770
  await ensureTelegramDaemonRunning({ settings, cwd: ctx.cwd, sessionId: id });
730
771
  } catch (e) {
@@ -963,7 +1004,10 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
963
1004
  api.on("turn_start", (_event, ctx) => {
964
1005
  const id = sessionId(ctx);
965
1006
  const rt = runtimes.get(id);
966
- if (!rt || rt.pendingInbound.size === 0) return;
1007
+ if (!rt) return;
1008
+ // A new turn is live: re-open the live-stream window (see turnClosed).
1009
+ rt.turnClosed = false;
1010
+ if (rt.pendingInbound.size === 0) return;
967
1011
  for (const updateId of rt.pendingInbound) {
968
1012
  try {
969
1013
  rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
@@ -1059,11 +1103,29 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1059
1103
  // rate-limit pool before sending to Telegram.
1060
1104
  // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
1061
1105
  // against what was already flushed for this turn (the pre-ask lead-in).
1062
- const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined): void => {
1106
+ const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
1063
1107
  if (!text || text === rt.preAskFlushedText) return;
1064
1108
  rt.preAskFlushedText = text;
1109
+ // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
1110
+ // live message, never a fresh (rich-promotable) send. If live frames were
1111
+ // async-queued and none landed before this flush, allocate the per-turn ref
1112
+ // now so the finalized frame always carries a messageRef → the daemon keeps it
1113
+ // editable (HTML edit) and never rich-promotes a streamed final.
1114
+ if (finalAnswer && rt.stream && rt.liveRef === undefined) {
1115
+ rt.turnSeq = (rt.turnSeq ?? 0) + 1;
1116
+ rt.liveRef = String(rt.turnSeq);
1117
+ }
1065
1118
  try {
1066
- rt.server.pushFrame(JSON.stringify({ type: "turn_stream", sessionId: id, phase: "finalized", text }));
1119
+ rt.server.pushFrame(
1120
+ JSON.stringify({
1121
+ type: "turn_stream",
1122
+ sessionId: id,
1123
+ phase: "finalized",
1124
+ finalAnswer,
1125
+ text,
1126
+ ...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
1127
+ }),
1128
+ );
1067
1129
  } catch (e) {
1068
1130
  logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
1069
1131
  }
@@ -1080,19 +1142,53 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1080
1142
  const id = sessionId(ctx);
1081
1143
  const rt = runtimes.get(id);
1082
1144
  if (!rt || rt.redact) return;
1083
- flushTurnText(rt, id, rt.currentTurnText);
1145
+ flushTurnText(rt, id, rt.currentTurnText, false);
1084
1146
  });
1085
1147
 
1086
1148
  api.on("turn_end", (event, ctx) => {
1087
1149
  const id = sessionId(ctx);
1088
1150
  const rt = runtimes.get(id);
1089
1151
  if (!rt) return;
1090
- const text = rt.redact ? undefined : summaryFromMessage(event.message, 3500);
1091
- if (text) flushTurnText(rt, id, text);
1152
+ const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
1153
+ if (text) flushTurnText(rt, id, text, true);
1092
1154
  // Reset per-turn streaming state so the next turn starts fresh and a later
1093
1155
  // turn with identical text is not falsely deduped.
1094
1156
  rt.currentTurnText = undefined;
1095
1157
  rt.preAskFlushedText = undefined;
1158
+ rt.liveRef = undefined;
1159
+ // Close the live-stream window: any message_update queued after turn_end is
1160
+ // dropped so it can never emit a stale live edit past the finalized turn.
1161
+ rt.turnClosed = true;
1162
+ rt.lastLiveAt = undefined;
1163
+ rt.lastLiveText = undefined;
1164
+ });
1165
+
1166
+ // Live streaming (opt-in): push throttled in-progress assistant text as
1167
+ // non-finalized turn_stream frames so remote clients edit one message as the
1168
+ // turn streams. The finalized frame (turn_end) carries the same messageRef and
1169
+ // lands the authoritative text. Suppressed under redaction.
1170
+ api.on("message_update", (event, ctx) => {
1171
+ const id = sessionId(ctx);
1172
+ const rt = runtimes.get(id);
1173
+ if (!rt?.stream || rt.redact || rt.turnClosed) return;
1174
+ if ((event.message as { role?: unknown }).role !== "assistant") return;
1175
+ if (rt.liveRef === undefined) {
1176
+ rt.turnSeq = (rt.turnSeq ?? 0) + 1;
1177
+ rt.liveRef = String(rt.turnSeq);
1178
+ }
1179
+ const now = Date.now();
1180
+ if (now - (rt.lastLiveAt ?? 0) < streamIntervalMs()) return;
1181
+ const text = summaryFromMessage(event.message, 3500);
1182
+ if (!text || text === rt.lastLiveText) return;
1183
+ rt.lastLiveAt = now;
1184
+ rt.lastLiveText = text;
1185
+ try {
1186
+ rt.server.pushFrame(
1187
+ JSON.stringify({ type: "turn_stream", sessionId: id, phase: "live", text, messageRef: rt.liveRef }),
1188
+ );
1189
+ } catch (e) {
1190
+ logger.warn(`notifications: pushFrame (live) failed: ${String(e)}`);
1191
+ }
1096
1192
  });
1097
1193
 
1098
1194
  // Stream agent-produced images (computer/browser/tool screenshots) as
@@ -1106,7 +1202,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1106
1202
  // flush can emit it before the ask prompt. Role-scoped: message_end also
1107
1203
  // fires for the user prompt, which must never be mirrored back as turn output.
1108
1204
  if ((event.message as { role?: unknown }).role === "assistant") {
1109
- const turnText = summaryFromMessage(event.message, 3500);
1205
+ const turnText = summaryFromMessage(event.message, turnTextMax());
1110
1206
  if (turnText) rt.currentTurnText = turnText;
1111
1207
  }
1112
1208
  for (const img of imageAttachmentsFromMessage(event.message)) {
@@ -31,7 +31,7 @@ function normalizeLifecycleCommandToken(
31
31
 
32
32
  /** A parsed, validated lifecycle command (transport identity added by caller). */
33
33
  export type ParsedLifecycleCommand =
34
- | { kind: "create"; target: SessionCreateTarget }
34
+ | { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
35
35
  | { kind: "close"; target: SessionCloseTarget }
36
36
  | { kind: "resume"; target: SessionResumeTarget }
37
37
  | { kind: "recent"; which: "create" | "resume" | "all" }
@@ -41,9 +41,9 @@ export type ParsedLifecycleCommand =
41
41
 
42
42
  const USAGE = [
43
43
  "Session commands:",
44
- "/session_create path <dir>",
45
- "/session_create worktree <repo> <branch>",
46
- "/session_create dir <newdir>",
44
+ "/session_create path <dir> [--mpreset <profile>]",
45
+ "/session_create worktree <repo> <branch> [--mpreset <profile>]",
46
+ "/session_create dir <newdir> [--mpreset <profile>]",
47
47
  "/session_close <sessionId>",
48
48
  "/session_resume <sessionId|prefix>",
49
49
  "/session_recent [create|resume]",
@@ -69,6 +69,23 @@ export function isLifecycleCommandText(
69
69
  return normalizeLifecycleCommandToken(rawCommand ?? "", ctx) !== undefined;
70
70
  }
71
71
 
72
+ /** Extract `--mpreset <name>` (or `--mpreset=<name>`) from args, returning remaining positional args. */
73
+ function extractModelPreset(args: string[]): { positional: string[]; modelPreset?: string } {
74
+ const positional: string[] = [];
75
+ let modelPreset: string | undefined;
76
+ for (let i = 0; i < args.length; i++) {
77
+ const arg = args[i]!;
78
+ if (arg === "--mpreset" && i + 1 < args.length) {
79
+ modelPreset = args[++i]!;
80
+ } else if (arg.startsWith("--mpreset=")) {
81
+ modelPreset = arg.slice("--mpreset=".length);
82
+ } else {
83
+ positional.push(arg);
84
+ }
85
+ }
86
+ return { positional, modelPreset };
87
+ }
88
+
72
89
  /**
73
90
  * Parse a paired-chat message into a lifecycle command. Returns `none` for
74
91
  * non-lifecycle text, `usage`/`reject` for malformed input (no side effect), or
@@ -121,29 +138,33 @@ export function parseLifecycleCommand(
121
138
  return { kind: "resume", target: { sessionIdOrPrefix: idOrPrefix } };
122
139
  }
123
140
 
124
- // /session_create <kind> ...
125
- const kind = args[0];
141
+ // /session_create <kind> ... [--mpreset <profile>]
142
+ const { positional, modelPreset } = extractModelPreset(args);
143
+ if (modelPreset !== undefined && !isSafeIdentifier(modelPreset)) {
144
+ return { kind: "reject", reason: "invalid_target", message: `Invalid model preset name.\n\n${USAGE}` };
145
+ }
146
+ const kind = positional[0];
126
147
  if (kind === "path") {
127
- if (args.length !== 2) return { kind: "usage", message: USAGE };
128
- const p = normalizeLifecyclePath(args[1]!);
148
+ if (positional.length !== 2) return { kind: "usage", message: USAGE };
149
+ const p = normalizeLifecyclePath(positional[1]!);
129
150
  if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
130
- return { kind: "create", target: { kind: "existing_path", path: p } };
151
+ return { kind: "create", target: { kind: "existing_path", path: p }, modelPreset };
131
152
  }
132
153
  if (kind === "dir") {
133
- if (args.length !== 2) return { kind: "usage", message: USAGE };
134
- const p = normalizeLifecyclePath(args[1]!);
154
+ if (positional.length !== 2) return { kind: "usage", message: USAGE };
155
+ const p = normalizeLifecyclePath(positional[1]!);
135
156
  if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
136
- return { kind: "create", target: { kind: "plain_dir", path: p } };
157
+ return { kind: "create", target: { kind: "plain_dir", path: p }, modelPreset };
137
158
  }
138
159
  if (kind === "worktree") {
139
- if (args.length !== 3) return { kind: "usage", message: USAGE };
140
- const repo = normalizeLifecyclePath(args[1]!);
141
- const branch = args[2]!;
160
+ if (positional.length !== 3) return { kind: "usage", message: USAGE };
161
+ const repo = normalizeLifecyclePath(positional[1]!);
162
+ const branch = positional[2]!;
142
163
  if (!repo) return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
143
164
  if (!isSafeBranch(branch)) {
144
165
  return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
145
166
  }
146
- return { kind: "create", target: { kind: "worktree", repo, branch } };
167
+ return { kind: "create", target: { kind: "worktree", repo, branch }, modelPreset };
147
168
  }
148
169
  return { kind: "usage", message: USAGE };
149
170
  }