@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,60 @@
|
|
|
1
|
+
Control a target session remotely — inspect its state and perform actions as if you were the user in that session.
|
|
2
|
+
|
|
3
|
+
The `target` parameter identifies which session to control:
|
|
4
|
+
- A session ID (e.g. "ses_xxx") to target a specific session
|
|
5
|
+
|
|
6
|
+
## Actions
|
|
7
|
+
|
|
8
|
+
Abort and pending-input replies work while the target session is running. Compaction and worktree actions require an idle target.
|
|
9
|
+
|
|
10
|
+
### `create` — Create a standalone primary session
|
|
11
|
+
Creates a normal top-level session in the sidebar. `target` is not used. Optional parameters:
|
|
12
|
+
- `title`: session title
|
|
13
|
+
- `scopeID` or `directory`: where to create it; defaults to the current scope. If you need a valid `scopeID`, call `scope_list` first. If you already know the absolute project path, pass `directory` instead.
|
|
14
|
+
- `workspace`: `{ "mode": "current" }`, `{ "mode": "existing", "target": "..." }`, or `{ "mode": "create", "name": "...", "baseRef": "current|fresh" }`
|
|
15
|
+
- `agent`: per-session agent override for new user messages
|
|
16
|
+
- `model`: per-session model override, `{ "providerID": "...", "modelID": "..." }`
|
|
17
|
+
- `mode`: `interactive` or `unattended`
|
|
18
|
+
- `controlProfile`: `guarded`, `autonomous`, or `full_access`
|
|
19
|
+
- `initialMessage`: enqueue a first user message and wake the new session asynchronously
|
|
20
|
+
|
|
21
|
+
### `status` — Query session state
|
|
22
|
+
Returns the session's current runtime status, pending questions, and pending permission requests. Use this before other actions to understand what the session needs. For example, before answering a question, call `status` first to see what questions are pending and what options are available.
|
|
23
|
+
|
|
24
|
+
### `compact` — Trigger compaction
|
|
25
|
+
Forces the target session to compact its conversation history. Useful when a child session is approaching context limits.
|
|
26
|
+
|
|
27
|
+
### `abort` — Abort execution
|
|
28
|
+
Stops any ongoing processing in the target session. Use when a delegated agent is stuck or doing the wrong thing. The target can be the calling session itself; this also interrupts the calling turn, so do not rely on a successful tool result or a follow-up response from it.
|
|
29
|
+
|
|
30
|
+
### `worktree_enter` — Create, enter, or switch the target session's worktree
|
|
31
|
+
Uses `worktreeTarget` as an existing worktree name/ID/branch/path when it matches; otherwise creates a new worktree using `worktreeTarget` as the name. Omit `worktreeTarget` to create a uniquely named worktree. Use `baseRef`, `baseRevision`, and `force` as needed.
|
|
32
|
+
|
|
33
|
+
### `worktree_leave` — Return the target session to the main checkout
|
|
34
|
+
Use `cleanup: "keep"` (default) to keep the worktree, or `cleanup: "remove_if_clean"` to permanently delete it after leaving when it has no uncommitted changes. Permanent deletion drops the registry entry and cannot be undone; use `worktree_archive` when the checkout may be needed again.
|
|
35
|
+
|
|
36
|
+
### `set_agent` — Set the target session's agent override
|
|
37
|
+
Requires `agent`. Future user messages without an explicit agent use this override.
|
|
38
|
+
|
|
39
|
+
### `set_model` — Set the target session's model override
|
|
40
|
+
Requires `model: { "providerID": "...", "modelID": "..." }`. Future user messages without an explicit model use this override.
|
|
41
|
+
|
|
42
|
+
### `set_mode` — Set interactive vs unattended behavior
|
|
43
|
+
Requires `mode: "interactive"` or `mode: "unattended"`. `modeSource` defaults to `session_control`.
|
|
44
|
+
|
|
45
|
+
### `set_control_profile` — Set the target session's permission control profile
|
|
46
|
+
Requires `controlProfile`. This changes the target session's guarded/autonomous/full-access profile.
|
|
47
|
+
|
|
48
|
+
### `question_reply` — Answer a pending question
|
|
49
|
+
Provide answers to a question that the target session's agent has asked. The `answers` format is an array of arrays of selected labels — one array per question, each containing the label(s) the user selected. For single-select questions, pass one label; for multiple-select, pass multiple. Use `status` first to discover pending question IDs and available options.
|
|
50
|
+
|
|
51
|
+
### `question_reject` — Dismiss a pending question
|
|
52
|
+
Reject a question the target session's agent has asked. The agent will receive an error and can adjust its approach.
|
|
53
|
+
|
|
54
|
+
### `permission_reply` — Approve or deny a permission request
|
|
55
|
+
Respond to a pending permission request in the target session. Use `reply: "once"` to approve this specific request, or `reply: "reject"` to deny it. When rejecting, you can optionally provide a `message` to guide the agent. Use `status` first to discover pending permission IDs.
|
|
56
|
+
|
|
57
|
+
## Typical workflow
|
|
58
|
+
1. To create a session in another project, call `scope_list` to discover available scope IDs, then call `session_control` with `action: "create"` and the chosen `scopeID`. For an existing session, call `action: "status"` first.
|
|
59
|
+
2. Use `set_agent`, `set_model`, `set_mode`, or worktree actions to adjust the target session.
|
|
60
|
+
3. Use `question_reply` or `permission_reply` when `status` reports pending user input.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Tool } from "@ericsanchezok/synergy-harness/tool/tool";
|
|
3
|
+
import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree";
|
|
4
|
+
import { Session } from "@ericsanchezok/synergy-harness/session";
|
|
5
|
+
import { PermissionNext } from "@ericsanchezok/synergy-harness/permission/next";
|
|
6
|
+
const parameters = z.object({
|
|
7
|
+
target: z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("Managed worktree name, ID, branch, or path. Defaults to the calling session's current worktree; use worktree_list to find another idle worktree."),
|
|
12
|
+
reason: z.string().optional().describe("Optional short explanation of why the worktree is finished."),
|
|
13
|
+
});
|
|
14
|
+
export const WorktreeArchiveTool = Tool.define("worktree_archive", {
|
|
15
|
+
description: "Archive a finished Synergy-managed git worktree. Use when isolated work is complete, including from the running owner turn, or to reclaim another idle worktree. Optional target accepts a name, ID, branch or path; omitted target selects the caller's current worktree. The caller returns to its original checkout without stopping or archiving the conversation. Other sessions retain their Workspace identity and restore the checkout when execution resumes. Dirty or ignored files, local-only or unverifiable commits, locks and active users keep the checkout; there is no force option. Reports archived/noop/denied, restored checkout and whether cleanup actually happened with its reason. Keeps the branch; retained checkouts can be re-entered with worktree_enter. Released clean checkouts retain a pinned Git recovery receipt; worktree_enter and session execution restore them on demand. Use worktree_leave when you only need to return to the original checkout and keep the worktree.",
|
|
16
|
+
parameters,
|
|
17
|
+
async execute(params, ctx) {
|
|
18
|
+
ctx.abort.throwIfAborted();
|
|
19
|
+
const session = await Session.get(ctx.sessionID);
|
|
20
|
+
const target = params.target ?? (session.workspace?.type === "git_worktree" ? session.workspace.path : undefined);
|
|
21
|
+
if (!target) {
|
|
22
|
+
return {
|
|
23
|
+
title: "worktree_archive",
|
|
24
|
+
metadata: {
|
|
25
|
+
action: "noop",
|
|
26
|
+
message: "No current worktree to archive. Use worktree_list and supply a managed target.",
|
|
27
|
+
},
|
|
28
|
+
output: "No current worktree to archive. Use worktree_list and supply a managed target.",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const tree = await Worktree.resolve(target);
|
|
32
|
+
if (!tree.managed || tree.isMain || tree.stale) {
|
|
33
|
+
throw new Worktree.CreateFailedError({
|
|
34
|
+
message: "Only an available Synergy-managed worktree can be archived. Use worktree_list to select one.",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await ctx.ask({
|
|
39
|
+
permission: "worktree_archive",
|
|
40
|
+
patterns: [tree.path],
|
|
41
|
+
metadata: { target: tree.id, branch: tree.branch, reason: params.reason },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (!(error instanceof PermissionNext.RejectedError || error instanceof PermissionNext.DeniedError))
|
|
46
|
+
throw error;
|
|
47
|
+
const message = "Worktree archive denied. No workspace binding or files were changed.";
|
|
48
|
+
return { title: "worktree_archive", metadata: { action: "denied", message }, output: message };
|
|
49
|
+
}
|
|
50
|
+
ctx.abort.throwIfAborted();
|
|
51
|
+
const result = await Worktree.archive({ sessionID: ctx.sessionID, target: tree.id, signal: ctx.abort });
|
|
52
|
+
const lines = [`Archived worktree "${tree.name}". Conversation remains available and this turn can continue.`];
|
|
53
|
+
if (result.restored)
|
|
54
|
+
lines.push(`Restored: ${result.restored.path}`);
|
|
55
|
+
if (result.cleanup.performed)
|
|
56
|
+
lines.push(`Checkout released; branch "${tree.branch}" and recovery commit retained. Referencing sessions and worktree_enter restore it on demand.`);
|
|
57
|
+
else if (result.cleanup.state === "unknown")
|
|
58
|
+
lines.push(`Checkout removal could not be confirmed.${result.cleanup.error ? ` ${result.cleanup.error}` : ""} Use worktree_list to inspect the target; do not assume it can be re-entered.`);
|
|
59
|
+
else
|
|
60
|
+
lines.push(`Checkout kept at ${tree.path}: ${result.cleanup.reason ?? "removal_failed"}.${result.cleanup.error ? ` ${result.cleanup.error}` : ""} Re-enter it with worktree_enter or retry archive after resolving the blocker.`);
|
|
61
|
+
if (result.cleanup.performed && result.cleanup.error)
|
|
62
|
+
lines.push(`Checkout release was verified after an error: ${result.cleanup.error}`);
|
|
63
|
+
const message = lines.join("\n");
|
|
64
|
+
return {
|
|
65
|
+
title: "worktree_archive",
|
|
66
|
+
output: message,
|
|
67
|
+
metadata: {
|
|
68
|
+
action: "archived",
|
|
69
|
+
worktree: { id: tree.id, name: tree.name, path: tree.path, branch: tree.branch },
|
|
70
|
+
restored: result.restored,
|
|
71
|
+
cleanup: result.cleanup,
|
|
72
|
+
message,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
import { Tool } from "@ericsanchezok/synergy-harness/tool/tool";
|
|
3
|
+
import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree";
|
|
4
|
+
import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context";
|
|
5
|
+
import { PermissionNext } from "@ericsanchezok/synergy-harness/permission/next";
|
|
6
|
+
import { EnforcementError } from "@ericsanchezok/synergy-harness/enforcement/errors";
|
|
7
|
+
const parameters = z.object({
|
|
8
|
+
target: z.string().min(1).optional().describe("Target worktree name, ID, branch, or path to enter"),
|
|
9
|
+
baseRef: z
|
|
10
|
+
.enum(["current", "fresh"])
|
|
11
|
+
.optional()
|
|
12
|
+
.default("current")
|
|
13
|
+
.describe("Base reference for new worktree: current HEAD or fresh from origin"),
|
|
14
|
+
baseRevision: z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1)
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Explicit git revision/ref/commit to create the worktree from. Overrides baseRef when provided."),
|
|
19
|
+
reason: z.string().optional().describe("Optional short note about why the worktree is being entered"),
|
|
20
|
+
force: z
|
|
21
|
+
.boolean()
|
|
22
|
+
.optional()
|
|
23
|
+
.default(false)
|
|
24
|
+
.describe("Force enter even if the current or target worktree has uncommitted changes"),
|
|
25
|
+
});
|
|
26
|
+
function buildWorkspaceMetadata(info) {
|
|
27
|
+
return {
|
|
28
|
+
type: "git_worktree",
|
|
29
|
+
path: info.path,
|
|
30
|
+
scopeID: info.scopeID,
|
|
31
|
+
worktreeID: info.id,
|
|
32
|
+
name: info.name,
|
|
33
|
+
branch: info.branch,
|
|
34
|
+
baseRef: info.baseRef,
|
|
35
|
+
baseRevision: info.baseRevision,
|
|
36
|
+
resolvedBaseCommit: info.resolvedBaseCommit,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function denialMetadata(action, reason, message) {
|
|
40
|
+
return {
|
|
41
|
+
title: "worktree_enter",
|
|
42
|
+
output: message,
|
|
43
|
+
metadata: { action: action, reason, message },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export const WorktreeEnterTool = Tool.define("worktree_enter", {
|
|
47
|
+
description: "Create or enter a git worktree for the current session. " +
|
|
48
|
+
"When already in a worktree, calling with a different 'target' switches to that worktree " +
|
|
49
|
+
"(leaving the current one). Calling with no 'target' switches to a new worktree. " +
|
|
50
|
+
"When 'target' matches an existing worktree (by name, ID, branch, or path), enter that worktree. " +
|
|
51
|
+
"When no match is found, create a new worktree using 'target' as the name. " +
|
|
52
|
+
"Omit 'target' to create a new worktree with an auto-generated unique name.",
|
|
53
|
+
parameters,
|
|
54
|
+
async execute(params, ctx) {
|
|
55
|
+
const currentWorkspace = ScopeContext.current.workspace;
|
|
56
|
+
if (currentWorkspace?.type === "git_worktree") {
|
|
57
|
+
const cw = currentWorkspace;
|
|
58
|
+
const currentName = (cw.name ?? cw.worktreeID ?? cw.path ?? "unknown");
|
|
59
|
+
const currentPath = cw.path;
|
|
60
|
+
const currentID = cw.worktreeID;
|
|
61
|
+
const currentBranch = cw.branch;
|
|
62
|
+
// Noop: target matches current worktree
|
|
63
|
+
if (params.target) {
|
|
64
|
+
const t = params.target;
|
|
65
|
+
if (t === currentID || t === currentName || t === currentPath || (currentBranch && t === currentBranch)) {
|
|
66
|
+
return {
|
|
67
|
+
title: "worktree_enter",
|
|
68
|
+
output: `Already in worktree "${currentName}" at ${currentPath}.`,
|
|
69
|
+
metadata: {
|
|
70
|
+
action: "entered",
|
|
71
|
+
reason: "already_in_this_worktree",
|
|
72
|
+
created: false,
|
|
73
|
+
message: `Session is already in worktree "${currentName}".`,
|
|
74
|
+
worktree: undefined,
|
|
75
|
+
workspace: currentWorkspace,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Switching: refuse if current worktree is dirty and force not set
|
|
81
|
+
const st = await Worktree.status(ctx.sessionID);
|
|
82
|
+
if (st.dirty !== false && !params.force) {
|
|
83
|
+
return denialMetadata("denied", "current_dirty", `Current worktree "${currentName}" has uncommitted changes. Use 'force' to switch without saving.`);
|
|
84
|
+
}
|
|
85
|
+
await Worktree.leave(ctx.sessionID);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await ctx.ask({
|
|
89
|
+
permission: "worktree_enter",
|
|
90
|
+
patterns: [params.target ?? "*"],
|
|
91
|
+
metadata: {
|
|
92
|
+
target: params.target,
|
|
93
|
+
baseRef: params.baseRef,
|
|
94
|
+
baseRevision: params.baseRevision,
|
|
95
|
+
reason: params.reason,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof PermissionNext.RejectedError ||
|
|
101
|
+
error instanceof PermissionNext.CorrectedError ||
|
|
102
|
+
error instanceof PermissionNext.DeniedError ||
|
|
103
|
+
error instanceof EnforcementError.PolicyDenied) {
|
|
104
|
+
return denialMetadata("denied", "user_denied", error instanceof PermissionNext.CorrectedError ? error.message : "User declined worktree for this task.");
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
if (params.target) {
|
|
109
|
+
try {
|
|
110
|
+
const worktrees = await Worktree.list();
|
|
111
|
+
const match = worktrees.find((wt) => wt.id === params.target ||
|
|
112
|
+
wt.name === params.target ||
|
|
113
|
+
wt.branch === params.target ||
|
|
114
|
+
wt.path === params.target);
|
|
115
|
+
if (match) {
|
|
116
|
+
const entered = await Worktree.enter({
|
|
117
|
+
sessionID: ctx.sessionID,
|
|
118
|
+
target: params.target,
|
|
119
|
+
force: params.force,
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
title: "worktree_enter",
|
|
123
|
+
output: `Entered existing worktree "${entered.name}" at ${entered.path}.`,
|
|
124
|
+
metadata: {
|
|
125
|
+
action: "entered",
|
|
126
|
+
created: false,
|
|
127
|
+
message: `Entered existing worktree "${entered.name}".`,
|
|
128
|
+
worktree: entered,
|
|
129
|
+
workspace: buildWorkspaceMetadata(entered),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error instanceof Worktree.NotGitError) {
|
|
136
|
+
return denialMetadata("denied", "not_git_scope", "Current scope is not a Git repository; git worktree is unavailable.");
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const created = await Worktree.create({
|
|
143
|
+
name: params.target,
|
|
144
|
+
sessionID: ctx.sessionID,
|
|
145
|
+
baseRef: params.baseRef,
|
|
146
|
+
baseRevision: params.baseRevision,
|
|
147
|
+
bind: true,
|
|
148
|
+
});
|
|
149
|
+
const baseLabel = params.baseRevision ?? `${params.baseRef} base`;
|
|
150
|
+
return {
|
|
151
|
+
title: "worktree_enter",
|
|
152
|
+
output: `Created and entered worktree "${created.name}" at ${created.path}.`,
|
|
153
|
+
metadata: {
|
|
154
|
+
action: "entered",
|
|
155
|
+
created: true,
|
|
156
|
+
message: `Created new worktree "${created.name}" from ${baseLabel}.`,
|
|
157
|
+
worktree: created,
|
|
158
|
+
workspace: buildWorkspaceMetadata(created),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error instanceof Worktree.NotGitError) {
|
|
164
|
+
return denialMetadata("denied", "not_git_scope", "Current scope is not a Git repository; git worktree is unavailable.");
|
|
165
|
+
}
|
|
166
|
+
if (error instanceof Worktree.NameGenerationFailedError ||
|
|
167
|
+
error instanceof Worktree.CreateFailedError ||
|
|
168
|
+
error instanceof Worktree.SetupConfigError ||
|
|
169
|
+
error instanceof Worktree.StartCommandFailedError) {
|
|
170
|
+
return denialMetadata("denied", "setup_failed", error instanceof Error ? error.message : "Worktree setup failed.");
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
import { Tool } from "@ericsanchezok/synergy-harness/tool/tool";
|
|
3
|
+
import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree";
|
|
4
|
+
import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context";
|
|
5
|
+
import { PermissionNext } from "@ericsanchezok/synergy-harness/permission/next";
|
|
6
|
+
const parameters = z.object({
|
|
7
|
+
cleanup: z
|
|
8
|
+
.enum(["keep", "remove_if_clean"])
|
|
9
|
+
.optional()
|
|
10
|
+
.default("keep")
|
|
11
|
+
.describe("Whether to destroy the worktree after leaving. 'keep' (default) leaves it in place. 'remove_if_clean' permanently deletes it when it has no uncommitted changes: the registry entry is removed, the branch is deleted when its content is already on the target branch, and worktree_enter cannot restore it. Use worktree_archive instead when the checkout may be needed again."),
|
|
12
|
+
reason: z.string().optional().describe("Optional short note about why the session is leaving the worktree"),
|
|
13
|
+
});
|
|
14
|
+
export const WorktreeLeaveTool = Tool.define("worktree_leave", {
|
|
15
|
+
description: "Leave the current git worktree and return to the main checkout. Unbinds the session from the worktree and " +
|
|
16
|
+
"restores the workspace to the main tree. The worktree stays on disk by default; the cleanup parameter can " +
|
|
17
|
+
"permanently delete it instead, and that deletion cannot be undone. Use worktree_archive when isolated work is " +
|
|
18
|
+
"finished and the checkout should stay restorable.",
|
|
19
|
+
parameters,
|
|
20
|
+
async execute(params, ctx) {
|
|
21
|
+
const workspace = ScopeContext.current.workspace;
|
|
22
|
+
if (!workspace || workspace.type !== "git_worktree") {
|
|
23
|
+
return {
|
|
24
|
+
title: "worktree_leave",
|
|
25
|
+
output: "Already on the main checkout. No worktree to leave.",
|
|
26
|
+
metadata: {
|
|
27
|
+
action: "noop",
|
|
28
|
+
reason: "already_on_main",
|
|
29
|
+
message: "Already on the main checkout. No worktree to leave.",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const worktreeID = workspace.worktreeID;
|
|
34
|
+
const worktreePath = workspace.path;
|
|
35
|
+
const worktreeName = workspace.name;
|
|
36
|
+
// Tri-state: `undefined` means the dirty probe could not answer, which is
|
|
37
|
+
// not the same as "clean" and must not be reported as "dirty".
|
|
38
|
+
let dirtyState;
|
|
39
|
+
if (params.cleanup === "remove_if_clean") {
|
|
40
|
+
const st = await Worktree.status(ctx.sessionID);
|
|
41
|
+
dirtyState = st.dirty;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
await ctx.ask({
|
|
45
|
+
permission: "worktree_leave",
|
|
46
|
+
patterns: [worktreePath],
|
|
47
|
+
metadata: {
|
|
48
|
+
previous: { type: workspace.type, path: worktreePath, name: worktreeName },
|
|
49
|
+
cleanup: params.cleanup,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof PermissionNext.RejectedError || error instanceof PermissionNext.DeniedError) {
|
|
55
|
+
return {
|
|
56
|
+
title: "worktree_leave",
|
|
57
|
+
output: "User declined leaving the worktree.",
|
|
58
|
+
metadata: {
|
|
59
|
+
action: "denied",
|
|
60
|
+
reason: "user_denied",
|
|
61
|
+
message: "User declined leaving the worktree.",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const previous = { type: workspace.type, path: worktreePath, name: worktreeName };
|
|
68
|
+
await Worktree.leave(ctx.sessionID);
|
|
69
|
+
const restored = { type: "main", path: ScopeContext.current.directory };
|
|
70
|
+
let cleanupResult = {
|
|
71
|
+
performed: false,
|
|
72
|
+
};
|
|
73
|
+
if (params.cleanup === "remove_if_clean" && worktreeID) {
|
|
74
|
+
if (dirtyState === false) {
|
|
75
|
+
try {
|
|
76
|
+
// The turn issuing this call still holds the worktree's git lock and
|
|
77
|
+
// use token, so removal must be told that the caller is its own turn.
|
|
78
|
+
await Worktree.remove({ sessionID: ctx.sessionID, target: worktreeID, force: false }, { insideCallerTurn: true });
|
|
79
|
+
cleanupResult = { performed: true };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// Leaving already succeeded, so a failed cleanup must not fail the
|
|
83
|
+
// turn: mark the worktree for the janitor and report the reason.
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
await Worktree.markLifecycle(worktreeID, "gc_candidate").catch(() => undefined);
|
|
86
|
+
cleanupResult = { performed: false, error: message, cleanupDeferred: true };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (dirtyState === true) {
|
|
90
|
+
cleanupResult = { performed: false, skippedReason: "dirty" };
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
cleanupResult = { performed: false, skippedReason: "unknown_dirty" };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const lines = [
|
|
97
|
+
`Left worktree "${worktreeName || worktreePath}" and returned to main checkout.`,
|
|
98
|
+
`Previous: ${worktreePath}`,
|
|
99
|
+
`Restored: ${restored.path}`,
|
|
100
|
+
];
|
|
101
|
+
if (cleanupResult.performed) {
|
|
102
|
+
lines.push("Worktree removed (was clean).");
|
|
103
|
+
}
|
|
104
|
+
else if (cleanupResult.skippedReason === "dirty") {
|
|
105
|
+
lines.push("Worktree kept (has uncommitted changes).");
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
title: "worktree_leave",
|
|
109
|
+
output: lines.join("\n"),
|
|
110
|
+
metadata: {
|
|
111
|
+
action: "left",
|
|
112
|
+
previous,
|
|
113
|
+
restored,
|
|
114
|
+
cleanup: cleanupResult,
|
|
115
|
+
message: lines.join("\n"),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
import { Tool } from "@ericsanchezok/synergy-harness/tool/tool";
|
|
3
|
+
import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree";
|
|
4
|
+
import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context";
|
|
5
|
+
const parameters = z.object({});
|
|
6
|
+
const description = "List all git worktrees in the current repository. Returns each worktree's path, branch, state, and a cleanup recommendation for non-main managed worktrees. The currently active worktree is marked.";
|
|
7
|
+
export const WorktreeListTool = Tool.define("worktree_list", {
|
|
8
|
+
description,
|
|
9
|
+
parameters,
|
|
10
|
+
async execute(_params, _ctx) {
|
|
11
|
+
const worktrees = await Worktree.list();
|
|
12
|
+
const ws = ScopeContext.current.workspace;
|
|
13
|
+
const activeWt = ws ? (worktrees.find((wt) => wt.path === ws.path) ?? null) : null;
|
|
14
|
+
const enriched = worktrees.map((wt) => {
|
|
15
|
+
const recommendation = getCleanupRecommendation(wt, activeWt);
|
|
16
|
+
return { ...wt, cleanupRecommendation: recommendation };
|
|
17
|
+
});
|
|
18
|
+
const activeDisplay = activeWt
|
|
19
|
+
? `${activeWt.name ?? activeWt.path}${activeWt.branch ? ` (${activeWt.branch})` : ""}`
|
|
20
|
+
: "none";
|
|
21
|
+
const message = `${worktrees.length} worktree${worktrees.length !== 1 ? "s" : ""} found. Active: ${activeDisplay}`;
|
|
22
|
+
return {
|
|
23
|
+
title: "worktree_list",
|
|
24
|
+
output: message,
|
|
25
|
+
metadata: {
|
|
26
|
+
action: "listed",
|
|
27
|
+
active: activeWt
|
|
28
|
+
? { id: activeWt.id, name: activeWt.name, path: activeWt.path, branch: activeWt.branch }
|
|
29
|
+
: null,
|
|
30
|
+
worktrees: enriched,
|
|
31
|
+
message,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
function getCleanupRecommendation(wt, active) {
|
|
37
|
+
if (active && wt.path === active.path)
|
|
38
|
+
return "keep";
|
|
39
|
+
if (wt.isMain)
|
|
40
|
+
return "keep";
|
|
41
|
+
if (!wt.managed)
|
|
42
|
+
return "external_do_not_manage";
|
|
43
|
+
if (wt.stale)
|
|
44
|
+
return "safe_to_remove";
|
|
45
|
+
// A lock Synergy did not write is never reclaimed, so surface it as a reason
|
|
46
|
+
// rather than letting the worktree look merely idle.
|
|
47
|
+
if (wt.locked !== undefined && !wt.locked.startsWith("synergy:v1:"))
|
|
48
|
+
return "locked_outside_synergy";
|
|
49
|
+
if (wt.dirty)
|
|
50
|
+
return "inspect_dirty";
|
|
51
|
+
return "keep";
|
|
52
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context";
|
|
2
|
+
import { ToolRegistry } from "@ericsanchezok/synergy-harness/tool/registry";
|
|
3
|
+
import { SessionControlTool } from "./tools/session-control.js";
|
|
4
|
+
import { WorktreeArchiveTool } from "./tools/worktree-archive.js";
|
|
5
|
+
import { WorktreeEnterTool } from "./tools/worktree-enter.js";
|
|
6
|
+
import { WorktreeLeaveTool } from "./tools/worktree-leave.js";
|
|
7
|
+
import { WorktreeListTool } from "./tools/worktree-list.js";
|
|
8
|
+
/**
|
|
9
|
+
* Project domain tool registration. Loaded through src/registration.ts.
|
|
10
|
+
*/
|
|
11
|
+
const runtimeState = RuntimeContext.state(() => ({
|
|
12
|
+
registered: false,
|
|
13
|
+
}));
|
|
14
|
+
export function registerProjectTools() {
|
|
15
|
+
const instanceState = runtimeState();
|
|
16
|
+
if (instanceState.registered)
|
|
17
|
+
return;
|
|
18
|
+
instanceState.registered = true;
|
|
19
|
+
ToolRegistry.registerToolProvider("project", () => [
|
|
20
|
+
SessionControlTool,
|
|
21
|
+
WorktreeEnterTool,
|
|
22
|
+
WorktreeLeaveTool,
|
|
23
|
+
WorktreeArchiveTool,
|
|
24
|
+
WorktreeListTool,
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
export var VcsBranchWatcher;
|
|
3
|
+
(function (VcsBranchWatcher) {
|
|
4
|
+
function create(input) {
|
|
5
|
+
let branch;
|
|
6
|
+
let timer;
|
|
7
|
+
let inFlight;
|
|
8
|
+
let refreshPending = false;
|
|
9
|
+
let disposed = false;
|
|
10
|
+
const idleWaiters = new Set();
|
|
11
|
+
const settled = () => !timer && !inFlight;
|
|
12
|
+
const resolveIdle = () => {
|
|
13
|
+
if (!settled())
|
|
14
|
+
return;
|
|
15
|
+
for (const resolve of idleWaiters)
|
|
16
|
+
resolve();
|
|
17
|
+
idleWaiters.clear();
|
|
18
|
+
};
|
|
19
|
+
const refresh = () => {
|
|
20
|
+
if (disposed || inFlight)
|
|
21
|
+
return;
|
|
22
|
+
inFlight = Promise.resolve()
|
|
23
|
+
.then(async () => {
|
|
24
|
+
while (!disposed && refreshPending) {
|
|
25
|
+
refreshPending = false;
|
|
26
|
+
const previous = branch;
|
|
27
|
+
const next = await input.resolve();
|
|
28
|
+
branch = next;
|
|
29
|
+
if (next !== previous)
|
|
30
|
+
await input.onChange(next, previous);
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.finally(() => {
|
|
34
|
+
inFlight = undefined;
|
|
35
|
+
if (!disposed && refreshPending)
|
|
36
|
+
refresh();
|
|
37
|
+
resolveIdle();
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
async start() {
|
|
42
|
+
branch = await input.resolve();
|
|
43
|
+
return branch;
|
|
44
|
+
},
|
|
45
|
+
current() {
|
|
46
|
+
return branch;
|
|
47
|
+
},
|
|
48
|
+
notify(file) {
|
|
49
|
+
if (disposed || path.basename(file) !== "HEAD")
|
|
50
|
+
return;
|
|
51
|
+
refreshPending = true;
|
|
52
|
+
if (timer || inFlight)
|
|
53
|
+
return;
|
|
54
|
+
timer = setTimeout(() => {
|
|
55
|
+
timer = undefined;
|
|
56
|
+
refresh();
|
|
57
|
+
}, input.debounceMs);
|
|
58
|
+
},
|
|
59
|
+
idle() {
|
|
60
|
+
if (settled())
|
|
61
|
+
return Promise.resolve();
|
|
62
|
+
return new Promise((resolve) => idleWaiters.add(resolve));
|
|
63
|
+
},
|
|
64
|
+
async dispose() {
|
|
65
|
+
disposed = true;
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
refreshPending = false;
|
|
69
|
+
timer = undefined;
|
|
70
|
+
await inFlight;
|
|
71
|
+
resolveIdle();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
VcsBranchWatcher.create = create;
|
|
76
|
+
})(VcsBranchWatcher || (VcsBranchWatcher = {}));
|