@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.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.
- package/AGENTS.md +30 -0
- package/README.md +16 -2
- package/dist/agents.js +11 -0
- package/dist/cli-adapter.js +8 -0
- package/dist/component.js +65 -0
- package/dist/config-schema.js +265 -0
- package/dist/execution/activity.js +78 -0
- package/dist/execution/content.js +184 -0
- package/dist/execution/json-sections.js +231 -0
- package/dist/execution/presentation.js +79 -0
- package/dist/execution/preview.js +13 -0
- package/dist/execution/process.js +125 -0
- package/dist/execution/projection.js +327 -0
- package/dist/execution/query.js +18 -0
- package/dist/execution/routes.js +189 -0
- package/dist/execution/schema.js +322 -0
- package/dist/execution/service.js +1993 -0
- package/dist/features.js +22 -0
- package/dist/http.js +65 -0
- package/dist/performance/analysis.js +330 -0
- package/dist/performance/catalog.js +391 -0
- package/dist/performance/dashboard.js +483 -0
- package/dist/performance/error.js +15 -0
- package/dist/performance/index.js +6 -0
- package/dist/performance/inflight.js +43 -0
- package/dist/performance/projection.js +138 -0
- package/dist/performance/prompt/base.txt +38 -0
- package/dist/performance/prompt/builder.js +21 -0
- package/dist/performance/routes/performance-route.js +439 -0
- package/dist/performance/schema.js +628 -0
- package/dist/performance/timeline.js +98 -0
- package/dist/performance/trace-detail.js +91 -0
- package/dist/presentation/service.js +270 -0
- package/dist/presentation/tools/workbench-view.js +65 -0
- package/dist/presentation/tools.js +10 -0
- package/dist/project/directories.js +363 -0
- package/dist/project/git-health.js +498 -0
- package/dist/project/ide.js +66 -0
- package/dist/project/migration.js +22 -0
- package/dist/project/routes/directories.js +89 -0
- package/dist/project/routes/git.js +30 -0
- package/dist/project/routes/task-defaults.js +42 -0
- package/dist/project/session-health.js +9 -0
- package/dist/project/startup.js +28 -0
- package/dist/project/task-defaults.js +68 -0
- package/dist/project/tools/session-control.js +664 -0
- package/dist/project/tools/session-control.txt +60 -0
- package/dist/project/tools/worktree-archive.js +76 -0
- package/dist/project/tools/worktree-enter.js +175 -0
- package/dist/project/tools/worktree-leave.js +119 -0
- package/dist/project/tools/worktree-list.js +52 -0
- package/dist/project/tools.js +26 -0
- package/dist/project/vcs-branch-watcher.js +76 -0
- package/dist/project/vcs.js +114 -0
- package/dist/project/worktree-janitor.js +140 -0
- package/dist/project/worktrees.js +98 -0
- package/dist/push/bridge.js +171 -0
- package/dist/push/index.js +4 -0
- package/dist/push/routes/push.js +224 -0
- package/dist/push/service.js +93 -0
- package/dist/push/store.js +121 -0
- package/dist/push/types.js +58 -0
- package/dist/review/git.js +226 -0
- package/dist/review/routes.js +45 -0
- package/dist/review/schema.js +32 -0
- package/dist/review/state.js +79 -0
- package/dist/stats/aggregator.js +252 -0
- package/dist/stats/canonical.js +89 -0
- package/dist/stats/cli/stats.js +302 -0
- package/dist/stats/engine.js +176 -0
- package/dist/stats/index.js +5 -0
- package/dist/stats/rollup.js +460 -0
- package/dist/stats/routes/stats.js +94 -0
- package/dist/stats/routes/usage.js +83 -0
- package/dist/stats/storage.js +86 -0
- package/dist/stats/types.js +267 -0
- package/dist/worker.js +6 -0
- package/package.json +232 -4
- package/src/agents.ts +15 -0
- package/src/cli-adapter.ts +11 -0
- package/src/component.ts +73 -0
- package/src/config-schema.ts +289 -0
- package/src/execution/activity.ts +83 -0
- package/src/execution/content.ts +189 -0
- package/src/execution/json-sections.ts +230 -0
- package/src/execution/presentation.ts +91 -0
- package/src/execution/preview.ts +12 -0
- package/src/execution/process.ts +118 -0
- package/src/execution/projection.ts +330 -0
- package/src/execution/query.ts +19 -0
- package/src/execution/routes.ts +324 -0
- package/src/execution/schema.ts +336 -0
- package/src/execution/service.ts +2149 -0
- package/src/features.ts +22 -0
- package/src/http.ts +75 -0
- package/src/performance/analysis.ts +356 -0
- package/src/performance/catalog.ts +902 -0
- package/src/performance/dashboard.ts +521 -0
- package/src/performance/error.ts +32 -0
- package/src/performance/index.ts +6 -0
- package/src/performance/inflight.ts +46 -0
- package/src/performance/projection.ts +143 -0
- package/src/performance/prompt/base.txt +38 -0
- package/src/performance/prompt/builder.ts +24 -0
- package/src/performance/routes/performance-route.ts +541 -0
- package/src/performance/schema.ts +695 -0
- package/src/performance/timeline.ts +100 -0
- package/src/performance/trace-detail.ts +97 -0
- package/src/presentation/service.ts +303 -0
- package/src/presentation/tools/workbench-view.ts +75 -0
- package/src/presentation/tools.ts +11 -0
- package/src/project/directories.ts +391 -0
- package/src/project/git-health.ts +577 -0
- package/src/project/ide.ts +80 -0
- package/src/project/migration.ts +24 -0
- package/src/project/routes/directories.ts +124 -0
- package/src/project/routes/git.ts +42 -0
- package/src/project/routes/task-defaults.ts +50 -0
- package/src/project/session-health.ts +10 -0
- package/src/project/startup.ts +28 -0
- package/src/project/task-defaults.ts +70 -0
- package/src/project/tools/session-control.ts +725 -0
- package/src/project/tools/session-control.txt +60 -0
- package/src/project/tools/worktree-archive.ts +92 -0
- package/src/project/tools/worktree-enter.ts +227 -0
- package/src/project/tools/worktree-leave.ts +140 -0
- package/src/project/tools/worktree-list.ts +68 -0
- package/src/project/tools.ts +29 -0
- package/src/project/vcs-branch-watcher.ts +81 -0
- package/src/project/vcs.ts +124 -0
- package/src/project/worktree-janitor.ts +153 -0
- package/src/project/worktrees.ts +110 -0
- package/src/push/bridge.ts +182 -0
- package/src/push/index.ts +4 -0
- package/src/push/routes/push.ts +267 -0
- package/src/push/service.ts +108 -0
- package/src/push/store.ts +120 -0
- package/src/push/types.ts +67 -0
- package/src/review/git.ts +221 -0
- package/src/review/routes.ts +65 -0
- package/src/review/schema.ts +34 -0
- package/src/review/state.ts +77 -0
- package/src/stats/aggregator.ts +277 -0
- package/src/stats/canonical.ts +93 -0
- package/src/stats/cli/stats.ts +348 -0
- package/src/stats/engine.ts +197 -0
- package/src/stats/index.ts +5 -0
- package/src/stats/rollup.ts +522 -0
- package/src/stats/routes/stats.ts +107 -0
- package/src/stats/routes/usage.ts +103 -0
- package/src/stats/storage.ts +78 -0
- package/src/stats/types.ts +351 -0
- 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
|
+
}
|