@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.0

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 (153) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +16 -2
  3. package/dist/agents.js +11 -0
  4. package/dist/cli-adapter.js +8 -0
  5. package/dist/component.js +65 -0
  6. package/dist/config-schema.js +265 -0
  7. package/dist/execution/activity.js +78 -0
  8. package/dist/execution/content.js +184 -0
  9. package/dist/execution/json-sections.js +231 -0
  10. package/dist/execution/presentation.js +79 -0
  11. package/dist/execution/preview.js +13 -0
  12. package/dist/execution/process.js +125 -0
  13. package/dist/execution/projection.js +327 -0
  14. package/dist/execution/query.js +18 -0
  15. package/dist/execution/routes.js +189 -0
  16. package/dist/execution/schema.js +322 -0
  17. package/dist/execution/service.js +1993 -0
  18. package/dist/features.js +22 -0
  19. package/dist/http.js +65 -0
  20. package/dist/performance/analysis.js +330 -0
  21. package/dist/performance/catalog.js +391 -0
  22. package/dist/performance/dashboard.js +483 -0
  23. package/dist/performance/error.js +15 -0
  24. package/dist/performance/index.js +6 -0
  25. package/dist/performance/inflight.js +43 -0
  26. package/dist/performance/projection.js +138 -0
  27. package/dist/performance/prompt/base.txt +38 -0
  28. package/dist/performance/prompt/builder.js +21 -0
  29. package/dist/performance/routes/performance-route.js +439 -0
  30. package/dist/performance/schema.js +628 -0
  31. package/dist/performance/timeline.js +98 -0
  32. package/dist/performance/trace-detail.js +91 -0
  33. package/dist/presentation/service.js +270 -0
  34. package/dist/presentation/tools/workbench-view.js +65 -0
  35. package/dist/presentation/tools.js +10 -0
  36. package/dist/project/directories.js +363 -0
  37. package/dist/project/git-health.js +498 -0
  38. package/dist/project/ide.js +66 -0
  39. package/dist/project/migration.js +22 -0
  40. package/dist/project/routes/directories.js +89 -0
  41. package/dist/project/routes/git.js +30 -0
  42. package/dist/project/routes/task-defaults.js +42 -0
  43. package/dist/project/session-health.js +9 -0
  44. package/dist/project/startup.js +28 -0
  45. package/dist/project/task-defaults.js +68 -0
  46. package/dist/project/tools/session-control.js +664 -0
  47. package/dist/project/tools/session-control.txt +60 -0
  48. package/dist/project/tools/worktree-archive.js +76 -0
  49. package/dist/project/tools/worktree-enter.js +175 -0
  50. package/dist/project/tools/worktree-leave.js +119 -0
  51. package/dist/project/tools/worktree-list.js +52 -0
  52. package/dist/project/tools.js +26 -0
  53. package/dist/project/vcs-branch-watcher.js +76 -0
  54. package/dist/project/vcs.js +114 -0
  55. package/dist/project/worktree-janitor.js +140 -0
  56. package/dist/project/worktrees.js +98 -0
  57. package/dist/push/bridge.js +171 -0
  58. package/dist/push/index.js +4 -0
  59. package/dist/push/routes/push.js +224 -0
  60. package/dist/push/service.js +93 -0
  61. package/dist/push/store.js +121 -0
  62. package/dist/push/types.js +58 -0
  63. package/dist/review/git.js +226 -0
  64. package/dist/review/routes.js +45 -0
  65. package/dist/review/schema.js +32 -0
  66. package/dist/review/state.js +79 -0
  67. package/dist/stats/aggregator.js +252 -0
  68. package/dist/stats/canonical.js +89 -0
  69. package/dist/stats/cli/stats.js +302 -0
  70. package/dist/stats/engine.js +176 -0
  71. package/dist/stats/index.js +5 -0
  72. package/dist/stats/rollup.js +460 -0
  73. package/dist/stats/routes/stats.js +94 -0
  74. package/dist/stats/routes/usage.js +83 -0
  75. package/dist/stats/storage.js +86 -0
  76. package/dist/stats/types.js +267 -0
  77. package/dist/worker.js +6 -0
  78. package/package.json +232 -4
  79. package/src/agents.ts +15 -0
  80. package/src/cli-adapter.ts +11 -0
  81. package/src/component.ts +73 -0
  82. package/src/config-schema.ts +289 -0
  83. package/src/execution/activity.ts +83 -0
  84. package/src/execution/content.ts +189 -0
  85. package/src/execution/json-sections.ts +230 -0
  86. package/src/execution/presentation.ts +91 -0
  87. package/src/execution/preview.ts +12 -0
  88. package/src/execution/process.ts +118 -0
  89. package/src/execution/projection.ts +330 -0
  90. package/src/execution/query.ts +19 -0
  91. package/src/execution/routes.ts +324 -0
  92. package/src/execution/schema.ts +336 -0
  93. package/src/execution/service.ts +2149 -0
  94. package/src/features.ts +22 -0
  95. package/src/http.ts +75 -0
  96. package/src/performance/analysis.ts +356 -0
  97. package/src/performance/catalog.ts +902 -0
  98. package/src/performance/dashboard.ts +521 -0
  99. package/src/performance/error.ts +32 -0
  100. package/src/performance/index.ts +6 -0
  101. package/src/performance/inflight.ts +46 -0
  102. package/src/performance/projection.ts +143 -0
  103. package/src/performance/prompt/base.txt +38 -0
  104. package/src/performance/prompt/builder.ts +24 -0
  105. package/src/performance/routes/performance-route.ts +541 -0
  106. package/src/performance/schema.ts +695 -0
  107. package/src/performance/timeline.ts +100 -0
  108. package/src/performance/trace-detail.ts +97 -0
  109. package/src/presentation/service.ts +303 -0
  110. package/src/presentation/tools/workbench-view.ts +75 -0
  111. package/src/presentation/tools.ts +11 -0
  112. package/src/project/directories.ts +391 -0
  113. package/src/project/git-health.ts +577 -0
  114. package/src/project/ide.ts +80 -0
  115. package/src/project/migration.ts +24 -0
  116. package/src/project/routes/directories.ts +124 -0
  117. package/src/project/routes/git.ts +42 -0
  118. package/src/project/routes/task-defaults.ts +50 -0
  119. package/src/project/session-health.ts +10 -0
  120. package/src/project/startup.ts +28 -0
  121. package/src/project/task-defaults.ts +70 -0
  122. package/src/project/tools/session-control.ts +725 -0
  123. package/src/project/tools/session-control.txt +60 -0
  124. package/src/project/tools/worktree-archive.ts +92 -0
  125. package/src/project/tools/worktree-enter.ts +227 -0
  126. package/src/project/tools/worktree-leave.ts +140 -0
  127. package/src/project/tools/worktree-list.ts +68 -0
  128. package/src/project/tools.ts +29 -0
  129. package/src/project/vcs-branch-watcher.ts +81 -0
  130. package/src/project/vcs.ts +124 -0
  131. package/src/project/worktree-janitor.ts +153 -0
  132. package/src/project/worktrees.ts +110 -0
  133. package/src/push/bridge.ts +182 -0
  134. package/src/push/index.ts +4 -0
  135. package/src/push/routes/push.ts +267 -0
  136. package/src/push/service.ts +108 -0
  137. package/src/push/store.ts +120 -0
  138. package/src/push/types.ts +67 -0
  139. package/src/review/git.ts +221 -0
  140. package/src/review/routes.ts +65 -0
  141. package/src/review/schema.ts +34 -0
  142. package/src/review/state.ts +77 -0
  143. package/src/stats/aggregator.ts +277 -0
  144. package/src/stats/canonical.ts +93 -0
  145. package/src/stats/cli/stats.ts +348 -0
  146. package/src/stats/engine.ts +197 -0
  147. package/src/stats/index.ts +5 -0
  148. package/src/stats/rollup.ts +522 -0
  149. package/src/stats/routes/stats.ts +107 -0
  150. package/src/stats/routes/usage.ts +103 -0
  151. package/src/stats/storage.ts +78 -0
  152. package/src/stats/types.ts +351 -0
  153. package/src/worker.ts +7 -0
@@ -0,0 +1,725 @@
1
+ import z from "zod"
2
+ import { Tool } from "@ericsanchezok/synergy-harness/tool/tool"
3
+ import { PermissionNext } from "@ericsanchezok/synergy-harness/permission/next"
4
+ import { Session } from "@ericsanchezok/synergy-harness/session"
5
+ import { SessionManager } from "@ericsanchezok/synergy-harness/session/manager"
6
+ import { SessionAbort } from "@ericsanchezok/synergy-harness/session/abort"
7
+ import { Identifier } from "@ericsanchezok/synergy-harness/id/id"
8
+ import { Agent } from "@ericsanchezok/synergy-harness/agent/agent"
9
+ import { MessageV2 } from "@ericsanchezok/synergy-harness/session/message-v2"
10
+ import { SessionInteraction } from "@ericsanchezok/synergy-harness/session/interaction"
11
+ import { Scope } from "@ericsanchezok/synergy-harness/scope"
12
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
13
+ import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree"
14
+ import DESCRIPTION from "./session-control.txt"
15
+
16
+ const Action = z.enum([
17
+ "create",
18
+ "status",
19
+ "compact",
20
+ "abort",
21
+ "worktree_enter",
22
+ "worktree_leave",
23
+ "set_agent",
24
+ "set_model",
25
+ "set_mode",
26
+ "set_control_profile",
27
+ "question_reply",
28
+ "question_reject",
29
+ "permission_reply",
30
+ ])
31
+
32
+ const ModelRef = z.object({
33
+ providerID: z.string().min(1),
34
+ modelID: z.string().min(1),
35
+ })
36
+
37
+ const WorkspaceSelection = z.discriminatedUnion("mode", [
38
+ z.object({
39
+ mode: z.literal("current"),
40
+ }),
41
+ z.object({
42
+ mode: z.literal("existing"),
43
+ target: z.string().min(1),
44
+ force: z.boolean().optional(),
45
+ }),
46
+ z.object({
47
+ mode: z.literal("create"),
48
+ name: z.string().optional(),
49
+ baseRef: z.enum(["current", "fresh"]).optional(),
50
+ baseRevision: z.string().min(1).optional(),
51
+ }),
52
+ ])
53
+
54
+ const parameters = z.object({
55
+ target: z.string().optional().describe("Target session ID (ses_xxx). Required for every action except create."),
56
+ action: Action.describe("The control action to perform on the target session."),
57
+ title: z.string().optional().describe("Title for create, or an updated title when supported by future actions."),
58
+ initialMessage: z
59
+ .string()
60
+ .optional()
61
+ .describe("Initial user message to enqueue after create. The new session processes it asynchronously."),
62
+ scopeID: z.string().optional().describe("Scope ID for create. Defaults to the current scope."),
63
+ directory: z.string().optional().describe("Project directory for create when scopeID is not provided."),
64
+ workspace: WorkspaceSelection.optional().describe("Workspace selection for create."),
65
+ agent: z.string().optional().describe("Agent name for create or set_agent."),
66
+ model: ModelRef.optional().describe("Model override for create or set_model."),
67
+ mode: SessionInteraction.Mode.optional().describe("Interaction mode for create or set_mode."),
68
+ modeSource: z.string().optional().describe("Optional source label for set_mode; defaults to session_control."),
69
+ controlProfile: z
70
+ .enum(["guarded", "autonomous", "full_access"])
71
+ .optional()
72
+ .describe("Control profile for create or set_control_profile."),
73
+ worktreeTarget: z.string().min(1).optional().describe("Worktree name, ID, branch, or path for worktree_enter."),
74
+ baseRef: z
75
+ .enum(["current", "fresh"])
76
+ .optional()
77
+ .default("current")
78
+ .describe("Base reference for worktree creation from worktree_enter."),
79
+ baseRevision: z.string().min(1).optional().describe("Explicit git revision/ref/commit for worktree creation."),
80
+ force: z.boolean().optional().default(false).describe("Force worktree switch/remove operations when supported."),
81
+ cleanup: z
82
+ .enum(["keep", "remove_if_clean"])
83
+ .optional()
84
+ .default("keep")
85
+ .describe("Cleanup behavior for worktree_leave."),
86
+ requestID: z
87
+ .string()
88
+ .optional()
89
+ .describe(
90
+ "ID of the pending question or permission request. Required for question_reply, question_reject, and permission_reply.",
91
+ ),
92
+ answers: z
93
+ .array(z.array(z.string()))
94
+ .optional()
95
+ .describe(
96
+ "Answers for question_reply. An array of arrays of selected labels — one array per question, each containing the label(s) selected.",
97
+ ),
98
+ reply: z
99
+ .enum(["once", "reject"])
100
+ .optional()
101
+ .describe("Reply for permission_reply. 'once' approves this request; 'reject' denies it."),
102
+ message: z.string().optional().describe("Optional feedback message when rejecting a permission request."),
103
+ })
104
+ type Parameters = z.infer<typeof parameters>
105
+
106
+ async function resolveSession(target: string): Promise<Session.Info> {
107
+ if (target.startsWith("ses_")) {
108
+ return SessionManager.requireSession(target)
109
+ }
110
+ throw new Error(`Invalid session target: "${target}". Use a session ID (ses_xxx).`)
111
+ }
112
+
113
+ export const SessionControlTool = Tool.define("session_control", {
114
+ description: DESCRIPTION,
115
+ parameters,
116
+ async execute(params, ctx) {
117
+ ctx.abort.throwIfAborted()
118
+ if (params.action === "create") {
119
+ return handleCreate(params, ctx)
120
+ }
121
+
122
+ if (!params.target) {
123
+ throw new Error(`target is required for ${params.action}`)
124
+ }
125
+
126
+ const session = await resolveSession(params.target)
127
+ const sessionID = session.id
128
+
129
+ if (params.action === "status") {
130
+ return handleStatus(sessionID)
131
+ }
132
+
133
+ const inSession = <T>(fn: () => Promise<T>) => SessionManager.run(sessionID, fn)
134
+ const inScope = <T>(fn: () => Promise<T>) =>
135
+ ScopeContext.provide({ scope: session.scope as Scope, workspace: session.workspace, fn })
136
+
137
+ switch (params.action) {
138
+ case "compact": {
139
+ return inSession(() => handleCompact(sessionID))
140
+ }
141
+ case "abort": {
142
+ return inScope(() => handleAbort(sessionID, ctx.abort))
143
+ }
144
+ case "worktree_enter": {
145
+ return inSession(() => handleWorktreeEnter(sessionID, params))
146
+ }
147
+ case "worktree_leave": {
148
+ return inSession(() => handleWorktreeLeave(sessionID, params))
149
+ }
150
+ case "set_agent": {
151
+ return handleSetAgent(sessionID, params)
152
+ }
153
+ case "set_model": {
154
+ return handleSetModel(sessionID, params)
155
+ }
156
+ case "set_mode": {
157
+ return handleSetMode(sessionID, params)
158
+ }
159
+ case "set_control_profile": {
160
+ return handleSetControlProfile(sessionID, params)
161
+ }
162
+ case "question_reply": {
163
+ if (!params.requestID) {
164
+ throw new Error("requestID is required for question_reply")
165
+ }
166
+ if (!params.answers) {
167
+ throw new Error("answers is required for question_reply")
168
+ }
169
+ return inScope(() => handleQuestionReply(params.requestID!, params.answers!))
170
+ }
171
+ case "question_reject": {
172
+ if (!params.requestID) {
173
+ throw new Error("requestID is required for question_reject")
174
+ }
175
+ return inScope(() => handleQuestionReject(params.requestID!))
176
+ }
177
+ case "permission_reply": {
178
+ if (!params.requestID) {
179
+ throw new Error("requestID is required for permission_reply")
180
+ }
181
+ if (!params.reply) {
182
+ throw new Error("reply is required for permission_reply")
183
+ }
184
+ return inScope(() => handlePermissionReply(params.requestID!, params.reply!, params.message))
185
+ }
186
+ }
187
+ },
188
+ })
189
+
190
+ async function handleStatus(sessionID: string) {
191
+ const runtime = SessionManager.getRuntime(sessionID)
192
+ const { Question } = await import("@ericsanchezok/synergy-local-runtime/question")
193
+ const pendingQuestions = await Question.list()
194
+ const sessionQuestions = pendingQuestions.filter((q) => q.sessionID === sessionID)
195
+ const pendingPermissions = await PermissionNext.list()
196
+ const sessionPermissions = pendingPermissions.filter((p) => p.sessionID === sessionID)
197
+ const session = await Session.get(sessionID)
198
+
199
+ const status = {
200
+ sessionID,
201
+ status: runtime?.status ?? { type: "idle" as const },
202
+ interaction: session?.interaction ?? SessionInteraction.interactive(),
203
+ controlProfile: await Session.resolveControlProfile(sessionID),
204
+ agentOverride: session?.agentOverride,
205
+ modelOverride: session?.modelOverride,
206
+ workspace: session?.workspace,
207
+ pendingQuestions: sessionQuestions.map((q) => ({
208
+ id: q.id,
209
+ questions: q.questions,
210
+ })),
211
+ pendingPermissions: sessionPermissions.map((p) => ({
212
+ id: p.id,
213
+ permission: p.permission,
214
+ patterns: p.patterns,
215
+ metadata: p.metadata,
216
+ })),
217
+ }
218
+
219
+ const parts: string[] = []
220
+ parts.push(`Session ${sessionID}: ${status.status.type}`)
221
+ if (status.interaction.mode === "unattended") parts.push(`Mode: unattended (${status.interaction.source ?? ""})`)
222
+ parts.push(`Control profile: ${status.controlProfile}`)
223
+ if (status.agentOverride) parts.push(`Agent override: ${status.agentOverride}`)
224
+ if (status.modelOverride)
225
+ parts.push(`Model override: ${status.modelOverride.providerID}/${status.modelOverride.modelID}`)
226
+ if (status.workspace) parts.push(`Workspace: ${status.workspace.type} at ${status.workspace.path}`)
227
+ if (status.pendingQuestions.length > 0) {
228
+ parts.push(`Pending questions: ${status.pendingQuestions.length}`)
229
+ for (const q of status.pendingQuestions) {
230
+ for (const question of q.questions) {
231
+ const opts = question.options.map((o) => `"${o.label}" (${o.description})`).join(", ")
232
+ parts.push(` [${q.id}] ${question.header}: ${question.question}`)
233
+ parts.push(` Options: ${opts}`)
234
+ if (question.multiple) parts.push(" (multiple selection allowed)")
235
+ }
236
+ }
237
+ }
238
+ if (status.pendingPermissions.length > 0) {
239
+ parts.push(`Pending permissions: ${status.pendingPermissions.length}`)
240
+ for (const p of status.pendingPermissions) {
241
+ const toolInfo = p.metadata?.tool
242
+ const toolLabel = toolInfo ? ` (tool: ${p.metadata?.toolName ?? "unknown"})` : ""
243
+ parts.push(` [${p.id}] ${p.permission} on ${p.patterns.join(", ")}${toolLabel}`)
244
+ }
245
+ }
246
+ if (status.pendingQuestions.length === 0 && status.pendingPermissions.length === 0) {
247
+ parts.push("No pending questions or permissions.")
248
+ }
249
+
250
+ return {
251
+ title: `Status of ${sessionID}`,
252
+ output: parts.join("\n"),
253
+ metadata: { status } as Record<string, any>,
254
+ }
255
+ }
256
+
257
+ async function requireAgent(name: string) {
258
+ const agent = await Agent.get(name)
259
+ if (!agent) {
260
+ throw new Error(`Agent "${name}" not found`)
261
+ }
262
+ return agent
263
+ }
264
+
265
+ async function resolveCreateScope(params: Parameters): Promise<Scope> {
266
+ if (params.scopeID) {
267
+ const scope = await Scope.fromID(params.scopeID)
268
+ if (!scope) throw new Error(`Scope "${params.scopeID}" not found`)
269
+ return scope
270
+ }
271
+ if (params.directory) {
272
+ return (await Scope.fromDirectory(params.directory)).scope
273
+ }
274
+ return ScopeContext.current.scope
275
+ }
276
+
277
+ function interactionFor(
278
+ mode: SessionInteraction.Mode | undefined,
279
+ source?: string,
280
+ ): SessionInteraction.Info | undefined {
281
+ if (!mode) return undefined
282
+ return mode === "unattended"
283
+ ? SessionInteraction.unattended(source ?? "session_control")
284
+ : SessionInteraction.interactive(source ?? "session_control")
285
+ }
286
+
287
+ function modelLabel(model: { providerID: string; modelID: string } | undefined) {
288
+ return model ? `${model.providerID}/${model.modelID}` : "default"
289
+ }
290
+
291
+ function sessionSummary(session: Session.Info) {
292
+ return {
293
+ id: session.id,
294
+ title: session.title,
295
+ scopeID: session.scope.id,
296
+ workspace: session.workspace,
297
+ interaction: session.interaction,
298
+ controlProfile: session.controlProfile,
299
+ agentOverride: session.agentOverride,
300
+ modelOverride: session.modelOverride,
301
+ }
302
+ }
303
+
304
+ async function handleCreate(params: Parameters, ctx: Tool.Context) {
305
+ const agent = params.agent ? await requireAgent(params.agent) : undefined
306
+ const scope = await resolveCreateScope(params)
307
+ const interaction = interactionFor(params.mode, params.modeSource)
308
+ let session = await Session.create({
309
+ scope,
310
+ title: params.title,
311
+ controlProfile: params.controlProfile,
312
+ agentOverride: agent?.name,
313
+ interaction,
314
+ })
315
+
316
+ try {
317
+ if (params.workspace) {
318
+ session = await ScopeContext.provide({
319
+ scope,
320
+ fn: () => Session.applyWorkspaceSelection(session.id, params.workspace),
321
+ })
322
+ }
323
+
324
+ if (params.model) {
325
+ session = await Session.setModelSelection(session.id, { model: params.model })
326
+ }
327
+
328
+ if (params.initialMessage?.trim()) {
329
+ const messageID = Identifier.ascending("message")
330
+ const textPart: MessageV2.TextPart = {
331
+ id: Identifier.ascending("part"),
332
+ sessionID: session.id,
333
+ messageID,
334
+ type: "text",
335
+ text: params.initialMessage,
336
+ }
337
+ await SessionManager.deliver({
338
+ target: session.id,
339
+ waitForProcessing: false,
340
+ mail: {
341
+ type: "user",
342
+ parts: [textPart],
343
+ agent: agent?.name,
344
+ model: params.model,
345
+ summary: params.title ? { title: params.title } : undefined,
346
+ metadata: {
347
+ source: "session_control",
348
+ sourceSessionID: ctx.sessionID,
349
+ sourceName: "session_control",
350
+ },
351
+ },
352
+ })
353
+ session = await Session.get(session.id)
354
+ }
355
+ } catch (error) {
356
+ await Session.remove(session.id).catch(() => undefined)
357
+ throw error
358
+ }
359
+
360
+ const lines = [`Created session ${session.id}: ${session.title}`]
361
+ if (agent) lines.push(`Agent override: ${agent.name}`)
362
+ if (params.model) lines.push(`Model override: ${modelLabel(params.model)}`)
363
+ if (interaction) lines.push(`Mode: ${interaction.mode}`)
364
+ if (params.workspace)
365
+ lines.push(
366
+ `Workspace: ${session.workspace?.type ?? "main"} at ${session.workspace?.path ?? scope.local?.directory}`,
367
+ )
368
+ if (params.initialMessage?.trim()) lines.push("Initial message queued for asynchronous processing.")
369
+
370
+ return {
371
+ title: `Created ${session.id}`,
372
+ output: lines.join("\n"),
373
+ metadata: {
374
+ action: "create",
375
+ session: sessionSummary(session),
376
+ initialMessageQueued: Boolean(params.initialMessage?.trim()),
377
+ } as Record<string, any>,
378
+ }
379
+ }
380
+
381
+ async function handleCompact(sessionID: string) {
382
+ const session = await Session.get(sessionID)
383
+ if (!session) {
384
+ throw new Error(`Session ${sessionID} not found`)
385
+ }
386
+
387
+ const msgs = await Session.messages({ sessionID })
388
+ let currentAgent = await Agent.defaultAgent()
389
+ for (let i = msgs.length - 1; i >= 0; i--) {
390
+ const info = msgs[i].info
391
+ if (info.role === "user") {
392
+ currentAgent = info.agent || (await Agent.defaultAgent())
393
+ break
394
+ }
395
+ }
396
+
397
+ const lastUserModel = msgs
398
+ .filter((m) => m.info.role === "user")
399
+ .map((m) => m.info as MessageV2.User)
400
+ .at(-1)?.model
401
+
402
+ const model = lastUserModel ?? (await Agent.getAvailableModel(await Agent.get(currentAgent)))
403
+ if (!model) {
404
+ throw new Error(`No model available for compaction in session ${sessionID}`)
405
+ }
406
+
407
+ const msg = await Session.updateMessage({
408
+ id: Identifier.ascending("message"),
409
+ role: "user",
410
+ model,
411
+ sessionID,
412
+ agent: currentAgent,
413
+ time: { created: Date.now() },
414
+ })
415
+ await Session.updatePart({
416
+ id: Identifier.ascending("part"),
417
+ messageID: msg.id,
418
+ sessionID,
419
+ type: "compaction",
420
+ auto: false,
421
+ })
422
+ await Session.updatePart({
423
+ id: Identifier.ascending("part"),
424
+ messageID: msg.id,
425
+ sessionID,
426
+ type: "text",
427
+ text: "What did we do so far?",
428
+ })
429
+
430
+ SessionManager.scheduleWake(sessionID, "session_control.compact")
431
+
432
+ return {
433
+ title: `Compacted ${sessionID}`,
434
+ output: `Compaction triggered for session ${sessionID}.`,
435
+ metadata: { sessionID, action: "compact" } as Record<string, any>,
436
+ }
437
+ }
438
+
439
+ async function handleAbort(sessionID: string, signal: AbortSignal) {
440
+ await SessionAbort.abort(sessionID, { signal })
441
+ return {
442
+ title: `Aborted ${sessionID}`,
443
+ output: `Session ${sessionID} has been aborted.`,
444
+ metadata: { sessionID, action: "abort" } as Record<string, any>,
445
+ }
446
+ }
447
+
448
+ async function handleSetAgent(sessionID: string, params: Parameters) {
449
+ if (!params.agent) throw new Error("agent is required for set_agent")
450
+ const agent = await requireAgent(params.agent)
451
+ const updated = await Session.update(sessionID, (draft) => {
452
+ draft.agentOverride = agent.name
453
+ })
454
+ return {
455
+ title: `Updated agent for ${sessionID}`,
456
+ output: `Session ${sessionID} will use agent "${agent.name}" for new user messages unless a message overrides it.`,
457
+ metadata: { action: "set_agent", session: sessionSummary(updated), agent: agent.name } as Record<string, any>,
458
+ }
459
+ }
460
+
461
+ async function handleSetModel(sessionID: string, params: Parameters) {
462
+ if (!params.model) throw new Error("model is required for set_model")
463
+ const updated = await Session.setModelSelection(sessionID, { model: params.model })
464
+ return {
465
+ title: `Updated model for ${sessionID}`,
466
+ output: `Session ${sessionID} model override set to ${modelLabel(params.model)}.`,
467
+ metadata: { action: "set_model", session: sessionSummary(updated), model: params.model } as Record<string, any>,
468
+ }
469
+ }
470
+
471
+ async function handleSetMode(sessionID: string, params: Parameters) {
472
+ if (!params.mode) throw new Error("mode is required for set_mode")
473
+ const interaction = interactionFor(params.mode, params.modeSource)!
474
+ const updated = await Session.update(sessionID, (draft) => {
475
+ draft.interaction = interaction
476
+ })
477
+ return {
478
+ title: `Updated mode for ${sessionID}`,
479
+ output: `Session ${sessionID} interaction mode set to ${interaction.mode}.`,
480
+ metadata: { action: "set_mode", session: sessionSummary(updated), interaction } as Record<string, any>,
481
+ }
482
+ }
483
+
484
+ async function handleSetControlProfile(sessionID: string, params: Parameters) {
485
+ if (!params.controlProfile) throw new Error("controlProfile is required for set_control_profile")
486
+ const updated = await Session.updateControlProfile(sessionID, params.controlProfile)
487
+ return {
488
+ title: `Updated control profile for ${sessionID}`,
489
+ output: `Session ${sessionID} control profile set to ${params.controlProfile}.`,
490
+ metadata: {
491
+ action: "set_control_profile",
492
+ session: sessionSummary(updated),
493
+ controlProfile: params.controlProfile,
494
+ } as Record<string, any>,
495
+ }
496
+ }
497
+
498
+ function workspaceMetadata(info: Worktree.Info): Record<string, unknown> {
499
+ return {
500
+ type: "git_worktree" as const,
501
+ path: info.path,
502
+ scopeID: info.scopeID,
503
+ worktreeID: info.id,
504
+ name: info.name,
505
+ branch: info.branch,
506
+ baseRef: info.baseRef,
507
+ baseRevision: info.baseRevision,
508
+ resolvedBaseCommit: info.resolvedBaseCommit,
509
+ }
510
+ }
511
+
512
+ function denied(action: string, reason: string, message: string) {
513
+ return {
514
+ title: action,
515
+ output: message,
516
+ metadata: { action: "denied", reason, message } as Record<string, any>,
517
+ }
518
+ }
519
+
520
+ async function handleWorktreeEnter(sessionID: string, params: Parameters) {
521
+ const currentWorkspace = ScopeContext.current.workspace
522
+ if (currentWorkspace?.type === "git_worktree") {
523
+ const current = currentWorkspace as Record<string, unknown>
524
+ const currentName = String(current.name ?? current.worktreeID ?? current.path ?? "unknown")
525
+ const currentPath = String(current.path)
526
+ const currentID = typeof current.worktreeID === "string" ? current.worktreeID : undefined
527
+ const currentBranch = typeof current.branch === "string" ? current.branch : undefined
528
+
529
+ if (
530
+ params.worktreeTarget &&
531
+ (params.worktreeTarget === currentID ||
532
+ params.worktreeTarget === currentName ||
533
+ params.worktreeTarget === currentPath ||
534
+ params.worktreeTarget === currentBranch)
535
+ ) {
536
+ return {
537
+ title: "worktree_enter",
538
+ output: `Session ${sessionID} is already in worktree "${currentName}" at ${currentPath}.`,
539
+ metadata: {
540
+ action: "worktree_enter",
541
+ status: "already_current",
542
+ created: false,
543
+ workspace: currentWorkspace,
544
+ } as Record<string, any>,
545
+ }
546
+ }
547
+
548
+ const status = await Worktree.status(sessionID)
549
+ if (status.dirty !== false && !params.force) {
550
+ return denied(
551
+ "worktree_enter",
552
+ "current_dirty",
553
+ `Current worktree "${currentName}" has uncommitted changes. Use force to switch without saving.`,
554
+ )
555
+ }
556
+
557
+ await Worktree.leave(sessionID)
558
+ }
559
+
560
+ try {
561
+ if (params.worktreeTarget) {
562
+ const worktrees = await Worktree.list()
563
+ const match = worktrees.find(
564
+ (item) =>
565
+ item.id === params.worktreeTarget ||
566
+ item.name === params.worktreeTarget ||
567
+ item.branch === params.worktreeTarget ||
568
+ item.path === params.worktreeTarget,
569
+ )
570
+ if (match) {
571
+ const entered = await Worktree.enter({
572
+ sessionID,
573
+ target: params.worktreeTarget,
574
+ force: params.force,
575
+ })
576
+ return {
577
+ title: "worktree_enter",
578
+ output: `Session ${sessionID} entered existing worktree "${entered.name}" at ${entered.path}.`,
579
+ metadata: {
580
+ action: "worktree_enter",
581
+ status: "entered",
582
+ created: false,
583
+ worktree: entered,
584
+ workspace: workspaceMetadata(entered),
585
+ } as Record<string, any>,
586
+ }
587
+ }
588
+ }
589
+
590
+ const created = await Worktree.create({
591
+ sessionID,
592
+ name: params.worktreeTarget,
593
+ baseRef: params.baseRef,
594
+ baseRevision: params.baseRevision,
595
+ bind: true,
596
+ })
597
+ const baseLabel = params.baseRevision ?? `${params.baseRef} base`
598
+ return {
599
+ title: "worktree_enter",
600
+ output: `Session ${sessionID} created and entered worktree "${created.name}" at ${created.path} from ${baseLabel}.`,
601
+ metadata: {
602
+ action: "worktree_enter",
603
+ status: "entered",
604
+ created: true,
605
+ worktree: created,
606
+ workspace: workspaceMetadata(created),
607
+ } as Record<string, any>,
608
+ }
609
+ } catch (error) {
610
+ if (error instanceof Worktree.NotGitError) {
611
+ return denied(
612
+ "worktree_enter",
613
+ "not_git_scope",
614
+ "Current scope is not a Git repository; git worktree is unavailable.",
615
+ )
616
+ }
617
+ throw error
618
+ }
619
+ }
620
+
621
+ async function handleWorktreeLeave(sessionID: string, params: Parameters) {
622
+ const workspace = ScopeContext.current.workspace
623
+
624
+ if (!workspace || workspace.type !== "git_worktree") {
625
+ return {
626
+ title: "worktree_leave",
627
+ output: `Session ${sessionID} is already on the main checkout. No worktree to leave.`,
628
+ metadata: {
629
+ action: "worktree_leave",
630
+ status: "noop",
631
+ reason: "already_on_main",
632
+ } as Record<string, any>,
633
+ }
634
+ }
635
+
636
+ const worktreeID =
637
+ typeof (workspace as any).worktreeID === "string" ? ((workspace as any).worktreeID as string) : undefined
638
+ const worktreePath = workspace.path
639
+ const worktreeName = typeof (workspace as any).name === "string" ? ((workspace as any).name as string) : undefined
640
+ const previous = { type: workspace.type, path: worktreePath, name: worktreeName }
641
+
642
+ // Tri-state: `undefined` means the dirty probe could not answer, which is not
643
+ // the same as "clean" and must not be reported as "dirty".
644
+ let dirtyState: boolean | undefined
645
+ if (params.cleanup === "remove_if_clean") {
646
+ const status = await Worktree.status(sessionID)
647
+ dirtyState = status.dirty
648
+ }
649
+
650
+ await Worktree.leave(sessionID)
651
+ const restored = { type: "main", path: ScopeContext.current.scope.local?.directory }
652
+ let cleanup: { performed: boolean; skippedReason?: string; error?: string; cleanupDeferred?: boolean } = {
653
+ performed: false,
654
+ }
655
+ if (params.cleanup === "remove_if_clean" && worktreeID) {
656
+ if (dirtyState === false) {
657
+ try {
658
+ // The turn issuing this call still holds the worktree's git lock and use
659
+ // token, so removal must be told that the caller is its own turn.
660
+ await Worktree.remove({ sessionID, target: worktreeID, force: false }, { insideCallerTurn: true })
661
+ cleanup = { performed: true }
662
+ } catch (error) {
663
+ // Leaving already succeeded, so a failed cleanup must not fail the turn.
664
+ const message = error instanceof Error ? error.message : String(error)
665
+ await Worktree.markLifecycle(worktreeID, "gc_candidate").catch(() => undefined)
666
+ cleanup = { performed: false, error: message, cleanupDeferred: true }
667
+ }
668
+ } else if (dirtyState === true) {
669
+ cleanup = { performed: false, skippedReason: "dirty" }
670
+ } else {
671
+ cleanup = { performed: false, skippedReason: "unknown_dirty" }
672
+ }
673
+ }
674
+
675
+ const lines = [
676
+ `Session ${sessionID} left worktree "${worktreeName || worktreePath}" and returned to main checkout.`,
677
+ `Previous: ${worktreePath}`,
678
+ `Restored: ${restored.path}`,
679
+ ]
680
+ if (cleanup.performed) lines.push("Worktree removed (was clean).")
681
+ if (cleanup.skippedReason === "dirty") lines.push("Worktree kept (has uncommitted changes).")
682
+
683
+ return {
684
+ title: "worktree_leave",
685
+ output: lines.join("\n"),
686
+ metadata: {
687
+ action: "worktree_leave",
688
+ status: "left",
689
+ previous,
690
+ restored,
691
+ cleanup,
692
+ } as Record<string, any>,
693
+ }
694
+ }
695
+
696
+ async function handleQuestionReply(requestID: string, answers: string[][]) {
697
+ const { Question } = await import("@ericsanchezok/synergy-local-runtime/question")
698
+ await Question.reply({ requestID, answers })
699
+ const formatted = answers.map((a) => a.join(", ")).join("; ")
700
+ return {
701
+ title: `Answered question ${requestID}`,
702
+ output: `Question ${requestID} answered: ${formatted}`,
703
+ metadata: { action: "question_reply", requestID, answers } as Record<string, any>,
704
+ }
705
+ }
706
+
707
+ async function handleQuestionReject(requestID: string) {
708
+ const { Question } = await import("@ericsanchezok/synergy-local-runtime/question")
709
+ await Question.reject(requestID)
710
+ return {
711
+ title: `Rejected question ${requestID}`,
712
+ output: `Question ${requestID} rejected.`,
713
+ metadata: { action: "question_reject", requestID } as Record<string, any>,
714
+ }
715
+ }
716
+
717
+ async function handlePermissionReply(requestID: string, reply: PermissionNext.Reply, message?: string) {
718
+ await PermissionNext.reply({ requestID, reply, message })
719
+ const desc = reply === "once" ? "approved" : "rejected"
720
+ return {
721
+ title: `${reply === "once" ? "Approved" : "Rejected"} permission ${requestID}`,
722
+ output: `Permission ${requestID} ${desc}.${message ? ` Feedback: ${message}` : ""}`,
723
+ metadata: { action: "permission_reply", requestID, reply, message } as Record<string, any>,
724
+ }
725
+ }