@gtc6244/immediacy-daemon 0.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.
- package/dist/src/actions.d.ts +22 -0
- package/dist/src/actions.js +44 -0
- package/dist/src/actions.js.map +1 -0
- package/dist/src/agent/agent.d.ts +89 -0
- package/dist/src/agent/agent.js +22 -0
- package/dist/src/agent/agent.js.map +1 -0
- package/dist/src/agent/claude.d.ts +70 -0
- package/dist/src/agent/claude.js +404 -0
- package/dist/src/agent/claude.js.map +1 -0
- package/dist/src/agent/codex.d.ts +44 -0
- package/dist/src/agent/codex.js +303 -0
- package/dist/src/agent/codex.js.map +1 -0
- package/dist/src/agent/mock.d.ts +31 -0
- package/dist/src/agent/mock.js +155 -0
- package/dist/src/agent/mock.js.map +1 -0
- package/dist/src/agent/model-error.d.ts +8 -0
- package/dist/src/agent/model-error.js +25 -0
- package/dist/src/agent/model-error.js.map +1 -0
- package/dist/src/agent/openrouter.d.ts +38 -0
- package/dist/src/agent/openrouter.js +60 -0
- package/dist/src/agent/openrouter.js.map +1 -0
- package/dist/src/agent/workspace-scope.d.ts +25 -0
- package/dist/src/agent/workspace-scope.js +71 -0
- package/dist/src/agent/workspace-scope.js.map +1 -0
- package/dist/src/allowlist.d.ts +56 -0
- package/dist/src/allowlist.js +141 -0
- package/dist/src/allowlist.js.map +1 -0
- package/dist/src/chatlog.d.ts +50 -0
- package/dist/src/chatlog.js +179 -0
- package/dist/src/chatlog.js.map +1 -0
- package/dist/src/cli/paths.d.ts +40 -0
- package/dist/src/cli/paths.js +76 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/service.d.ts +20 -0
- package/dist/src/cli/service.js +245 -0
- package/dist/src/cli/service.js.map +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +227 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +33 -0
- package/dist/src/config.js +122 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/credentials.d.ts +38 -0
- package/dist/src/credentials.js +120 -0
- package/dist/src/credentials.js.map +1 -0
- package/dist/src/crypto.d.ts +49 -0
- package/dist/src/crypto.js +202 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/daemon-identity.d.ts +31 -0
- package/dist/src/daemon-identity.js +97 -0
- package/dist/src/daemon-identity.js.map +1 -0
- package/dist/src/envelope.d.ts +14 -0
- package/dist/src/envelope.js +28 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/events.d.ts +40 -0
- package/dist/src/events.js +24 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/forward.d.ts +51 -0
- package/dist/src/forward.js +170 -0
- package/dist/src/forward.js.map +1 -0
- package/dist/src/fs-scope.d.ts +17 -0
- package/dist/src/fs-scope.js +47 -0
- package/dist/src/fs-scope.js.map +1 -0
- package/dist/src/github.d.ts +70 -0
- package/dist/src/github.js +178 -0
- package/dist/src/github.js.map +1 -0
- package/dist/src/gitops.d.ts +113 -0
- package/dist/src/gitops.js +557 -0
- package/dist/src/gitops.js.map +1 -0
- package/dist/src/hub.d.ts +50 -0
- package/dist/src/hub.js +92 -0
- package/dist/src/hub.js.map +1 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +56 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/jobs/scheduler.d.ts +94 -0
- package/dist/src/jobs/scheduler.js +373 -0
- package/dist/src/jobs/scheduler.js.map +1 -0
- package/dist/src/jobs/store.d.ts +33 -0
- package/dist/src/jobs/store.js +111 -0
- package/dist/src/jobs/store.js.map +1 -0
- package/dist/src/log.d.ts +61 -0
- package/dist/src/log.js +166 -0
- package/dist/src/log.js.map +1 -0
- package/dist/src/migrate-state-dir.d.ts +1 -0
- package/dist/src/migrate-state-dir.js +47 -0
- package/dist/src/migrate-state-dir.js.map +1 -0
- package/dist/src/models.d.ts +36 -0
- package/dist/src/models.js +71 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/permission-gate.d.ts +48 -0
- package/dist/src/permission-gate.js +93 -0
- package/dist/src/permission-gate.js.map +1 -0
- package/dist/src/permission-scope.d.ts +77 -0
- package/dist/src/permission-scope.js +244 -0
- package/dist/src/permission-scope.js.map +1 -0
- package/dist/src/ports.d.ts +71 -0
- package/dist/src/ports.js +216 -0
- package/dist/src/ports.js.map +1 -0
- package/dist/src/projects.d.ts +83 -0
- package/dist/src/projects.js +205 -0
- package/dist/src/projects.js.map +1 -0
- package/dist/src/protocol.d.ts +1226 -0
- package/dist/src/protocol.js +16 -0
- package/dist/src/protocol.js.map +1 -0
- package/dist/src/repo-sync.d.ts +49 -0
- package/dist/src/repo-sync.js +83 -0
- package/dist/src/repo-sync.js.map +1 -0
- package/dist/src/server.d.ts +33 -0
- package/dist/src/server.js +973 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/terminal.d.ts +32 -0
- package/dist/src/terminal.js +92 -0
- package/dist/src/terminal.js.map +1 -0
- package/dist/src/titler.d.ts +15 -0
- package/dist/src/titler.js +72 -0
- package/dist/src/titler.js.map +1 -0
- package/dist/src/types.d.ts +99 -0
- package/dist/src/types.js +3 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/usage-limit.d.ts +49 -0
- package/dist/src/usage-limit.js +83 -0
- package/dist/src/usage-limit.js.map +1 -0
- package/dist/src/usage.d.ts +23 -0
- package/dist/src/usage.js +63 -0
- package/dist/src/usage.js.map +1 -0
- package/dist/src/workspace.d.ts +303 -0
- package/dist/src/workspace.js +1086 -0
- package/dist/src/workspace.js.map +1 -0
- package/dist/src/worktree.d.ts +133 -0
- package/dist/src/worktree.js +561 -0
- package/dist/src/worktree.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceManager — owns the set of workspaces and, per workspace, its
|
|
3
|
+
* worktree, agent, and terminal. Translates agent events into broadcast wire
|
|
4
|
+
* messages and persists metadata so sessions survive a daemon restart (resume).
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { ChatLog } from "./chatlog.js";
|
|
12
|
+
import { formatTurnUsage } from "./usage.js";
|
|
13
|
+
import { ProjectStore } from "./projects.js";
|
|
14
|
+
import { AppConfigStore } from "./config.js";
|
|
15
|
+
import { CredentialsStore } from "./credentials.js";
|
|
16
|
+
import { AllowlistStore } from "./allowlist.js";
|
|
17
|
+
import { MockAgent } from "./agent/mock.js";
|
|
18
|
+
import { ClaudeAgent } from "./agent/claude.js";
|
|
19
|
+
import { CodexAgent } from "./agent/codex.js";
|
|
20
|
+
import { OpenRouterAgent } from "./agent/openrouter.js";
|
|
21
|
+
import { providerFor } from "./models.js";
|
|
22
|
+
import { Terminal } from "./terminal.js";
|
|
23
|
+
import { createWorktree, createWorktreeFromExistingBranch, createWorktreeFromPr, listBranches, removeWorktree, deleteLocalBranch, currentBranch, diffWorktree, diffStat, ensureCleanWorktree, listTree, readWorktreeFile, writeWorktreeFile, } from "./worktree.js";
|
|
24
|
+
import { gitStatus, gitCommit, gitPush, gitResolve, gitMerge, prCreate, prMerge, detectMerged, prInfo, openWorktree, originUrl } from "./gitops.js";
|
|
25
|
+
import { maybeSyncRepo } from "./repo-sync.js";
|
|
26
|
+
import { listPullRequests, prHeadRef, listIssues, createIssue } from "./github.js";
|
|
27
|
+
import { composeActionPrompt } from "./actions.js";
|
|
28
|
+
import { generateChatTitle } from "./titler.js";
|
|
29
|
+
const STATE_FILE = process.env.STATE_FILE ?? join(homedir(), ".immediacy", "state.json");
|
|
30
|
+
/** Root for repo-less chat workspaces' scratch dirs (one per chat). */
|
|
31
|
+
const CHATS_DIR = process.env.CHATS_DIR ?? join(homedir(), ".immediacy", "chats");
|
|
32
|
+
const AGENT_MODE = (process.env.AGENT_MODE ?? "claude").toLowerCase();
|
|
33
|
+
/**
|
|
34
|
+
* The sidebar-label form of a branch/ref: drops a leading owner/account segment
|
|
35
|
+
* so the label reads as the work, not who pushed it —
|
|
36
|
+
* `"Brutus-GTC6245/amman"` → `"amman"`. Only the first segment (the account) is
|
|
37
|
+
* removed; any deeper path is kept, and a ref with no `/` is returned unchanged.
|
|
38
|
+
*/
|
|
39
|
+
function branchLabel(ref) {
|
|
40
|
+
const i = ref.indexOf("/");
|
|
41
|
+
const label = i >= 0 ? ref.slice(i + 1) : ref;
|
|
42
|
+
return label || ref;
|
|
43
|
+
}
|
|
44
|
+
export class WorkspaceManager {
|
|
45
|
+
hub;
|
|
46
|
+
chatlog;
|
|
47
|
+
events;
|
|
48
|
+
projects;
|
|
49
|
+
credentials;
|
|
50
|
+
appConfig;
|
|
51
|
+
allowlist;
|
|
52
|
+
workspaces = new Map();
|
|
53
|
+
/**
|
|
54
|
+
* The last workspace list we fanned out, JSON-encoded — so
|
|
55
|
+
* `broadcastWorkspaceList` can no-op when a recompute yields identical rows.
|
|
56
|
+
*/
|
|
57
|
+
lastListJson = "";
|
|
58
|
+
constructor(hub,
|
|
59
|
+
/** Durable per-workspace transcript, so history survives phone reboots. */
|
|
60
|
+
chatlog = new ChatLog(),
|
|
61
|
+
/**
|
|
62
|
+
* In-process domain-event bus. Optional so existing callers/tests need no
|
|
63
|
+
* change; when present, the manager emits at each action chokepoint so the
|
|
64
|
+
* JobScheduler can drive reactive ("N minutes after X") triggers.
|
|
65
|
+
*/
|
|
66
|
+
events,
|
|
67
|
+
/** Registry of projects worktrees hang under (persisted separately). */
|
|
68
|
+
projects = new ProjectStore(),
|
|
69
|
+
/**
|
|
70
|
+
* Provider API keys (app-managed, env-seeded). Read at agent-spawn time to
|
|
71
|
+
* pick a session's credential: a stored/env key, else the CLI/SDK's own SSO
|
|
72
|
+
* login. Defaulted so existing callers/tests need no change.
|
|
73
|
+
*/
|
|
74
|
+
credentials = new CredentialsStore(),
|
|
75
|
+
/**
|
|
76
|
+
* Daemon-owned app config (prompts + git behavior). Read live at spawn time
|
|
77
|
+
* (`prompts.general` → agent system prompt) and at worktree-create time
|
|
78
|
+
* (`git.branchPrefix*`). Defaulted so existing callers/tests need no change.
|
|
79
|
+
*/
|
|
80
|
+
appConfig = new AppConfigStore(),
|
|
81
|
+
/**
|
|
82
|
+
* Daemon-global "Allow all" list. Handed to every spawned agent so a prior
|
|
83
|
+
* grant auto-approves matching calls, and appended to when a client answers
|
|
84
|
+
* a permission request with `allow_all`. Defaulted for existing callers/tests.
|
|
85
|
+
*/
|
|
86
|
+
allowlist = new AllowlistStore()) {
|
|
87
|
+
this.hub = hub;
|
|
88
|
+
this.chatlog = chatlog;
|
|
89
|
+
this.events = events;
|
|
90
|
+
this.projects = projects;
|
|
91
|
+
this.credentials = credentials;
|
|
92
|
+
this.appConfig = appConfig;
|
|
93
|
+
this.allowlist = allowlist;
|
|
94
|
+
}
|
|
95
|
+
/** The daemon-global allowlist (for server-side admin: list/revoke/clear). */
|
|
96
|
+
allowlistStore() {
|
|
97
|
+
return this.allowlist;
|
|
98
|
+
}
|
|
99
|
+
/** Is there a live workspace with this id? (used by the job scheduler). */
|
|
100
|
+
has(id) {
|
|
101
|
+
return this.workspaces.has(id);
|
|
102
|
+
}
|
|
103
|
+
async load() {
|
|
104
|
+
await this.chatlog.load();
|
|
105
|
+
await this.projects.load();
|
|
106
|
+
let migrated = false;
|
|
107
|
+
try {
|
|
108
|
+
const raw = await readFile(STATE_FILE, "utf8");
|
|
109
|
+
const records = JSON.parse(raw);
|
|
110
|
+
for (const r of records) {
|
|
111
|
+
// Worktrees may have been removed out of band; only keep live ones.
|
|
112
|
+
if (!existsSync(r.worktreePath))
|
|
113
|
+
continue;
|
|
114
|
+
let projectId = r.projectId;
|
|
115
|
+
// Legacy records (pre-projects) or ones pointing at a since-removed
|
|
116
|
+
// project get find-or-created a project from their repo so every
|
|
117
|
+
// worktree still groups under something.
|
|
118
|
+
if (!projectId || !this.projects.get(projectId)) {
|
|
119
|
+
const project = await this.projects.ensureForSource({ repo: r.repo });
|
|
120
|
+
projectId = project.id;
|
|
121
|
+
migrated = true;
|
|
122
|
+
}
|
|
123
|
+
this.workspaces.set(r.id, {
|
|
124
|
+
record: { ...r, projectId, status: "idle" },
|
|
125
|
+
terminals: new Map(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
console.log(`[ws] restored ${this.workspaces.size} workspace(s)`);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* no prior state */
|
|
132
|
+
}
|
|
133
|
+
if (migrated)
|
|
134
|
+
await this.persist();
|
|
135
|
+
}
|
|
136
|
+
/** Registered projects (the sidebar groups). */
|
|
137
|
+
listProjects() {
|
|
138
|
+
return this.projects.list();
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Reorder the registered projects. `ids` is the full list of project ids in
|
|
142
|
+
* the desired display order (see ProjectStore.reorder for how unknown/missing
|
|
143
|
+
* ids are handled).
|
|
144
|
+
*/
|
|
145
|
+
async reorderProjects(ids) {
|
|
146
|
+
await this.projects.reorder(ids);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Register a project from a blank repo (`git init`), a GitHub source, or an
|
|
150
|
+
* existing local path. GitHub/local are idempotent (by full name / resolved
|
|
151
|
+
* path); blank always creates. Does NOT create a worktree — it's a standing
|
|
152
|
+
* "+ new worktree" target.
|
|
153
|
+
*/
|
|
154
|
+
async createProject(opts) {
|
|
155
|
+
return this.projects.ensureForSource(opts);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Branches available to base a new worktree on for a project (local + remote,
|
|
159
|
+
* deduped). Powers the "create worktree from an existing branch" pickers.
|
|
160
|
+
*/
|
|
161
|
+
async listBranches(projectId) {
|
|
162
|
+
const project = this.projects.get(projectId);
|
|
163
|
+
if (!project)
|
|
164
|
+
throw new Error(`unknown project: ${projectId}`);
|
|
165
|
+
const branches = await listBranches(project.repo);
|
|
166
|
+
return { branches, defaultBranch: project.github?.defaultBranch };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* A project's open pull requests, for the "create worktree from an open PR"
|
|
170
|
+
* picker. Empty when the project has no GitHub remote or `gh` isn't available.
|
|
171
|
+
*/
|
|
172
|
+
async listPullRequests(projectId) {
|
|
173
|
+
const project = this.projects.get(projectId);
|
|
174
|
+
if (!project)
|
|
175
|
+
throw new Error(`unknown project: ${projectId}`);
|
|
176
|
+
return listPullRequests(project.repo);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A project's open issues (with bodies), for the "create worktree from an
|
|
180
|
+
* issue" picker + confirm step. Empty when there's no GitHub remote / gh.
|
|
181
|
+
*/
|
|
182
|
+
async listIssues(projectId) {
|
|
183
|
+
const project = this.projects.get(projectId);
|
|
184
|
+
if (!project)
|
|
185
|
+
throw new Error(`unknown project: ${projectId}`);
|
|
186
|
+
return listIssues(project.repo);
|
|
187
|
+
}
|
|
188
|
+
/** Remove a project. Rejected while any worktree still references it. */
|
|
189
|
+
async deleteProject(id) {
|
|
190
|
+
const inUse = [...this.workspaces.values()].some((l) => l.record.projectId === id);
|
|
191
|
+
if (inUse) {
|
|
192
|
+
throw new Error("project still has worktrees; remove them first");
|
|
193
|
+
}
|
|
194
|
+
await this.projects.remove(id);
|
|
195
|
+
}
|
|
196
|
+
/** Persisted transcript for a workspace (chronological). */
|
|
197
|
+
history(id) {
|
|
198
|
+
return this.chatlog.entries(id);
|
|
199
|
+
}
|
|
200
|
+
async persist() {
|
|
201
|
+
await mkdir(dirname(STATE_FILE), { recursive: true });
|
|
202
|
+
const records = [...this.workspaces.values()].map((l) => l.record);
|
|
203
|
+
await writeFile(STATE_FILE, JSON.stringify(records, null, 2), "utf8");
|
|
204
|
+
}
|
|
205
|
+
list() {
|
|
206
|
+
return [...this.workspaces.values()].map((l) => l.record);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Create a workspace (worktree) under a project. Preferred: pass `projectId`.
|
|
210
|
+
* The legacy path — a bare `repo`/`github` with no projectId — find-or-creates
|
|
211
|
+
* the matching project first, so every worktree ends up grouped.
|
|
212
|
+
*/
|
|
213
|
+
async create(opts) {
|
|
214
|
+
// Resolve the owning project: an explicit id, or find-or-create from source.
|
|
215
|
+
let project;
|
|
216
|
+
if (opts.projectId) {
|
|
217
|
+
project = this.projects.get(opts.projectId);
|
|
218
|
+
if (!project)
|
|
219
|
+
throw new Error(`unknown project: ${opts.projectId}`);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
project = await this.projects.ensureForSource({
|
|
223
|
+
repo: opts.repo,
|
|
224
|
+
github: opts.github,
|
|
225
|
+
name: opts.name,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const repo = project.repo;
|
|
229
|
+
// GitHub-backed projects branch off the remote default unless overridden.
|
|
230
|
+
const gh = opts.github ?? project.github;
|
|
231
|
+
const branchBase = opts.branchBase ??
|
|
232
|
+
(gh ? `origin/${gh.defaultBranch ?? "HEAD"}` : undefined);
|
|
233
|
+
const id = randomUUID();
|
|
234
|
+
const sourceBranch = opts.sourceBranch?.trim();
|
|
235
|
+
const sourcePr = opts.sourcePr;
|
|
236
|
+
// Resolve the PR's head branch up front so it can also seed the workspace
|
|
237
|
+
// name (a meaningful label) and drive the checkout below.
|
|
238
|
+
const prHead = sourcePr ? await prHeadRef(repo, sourcePr) : undefined;
|
|
239
|
+
// When basing off an existing branch or PR, default the workspace name to
|
|
240
|
+
// that branch (a meaningful label), minus any owner/account prefix
|
|
241
|
+
// (branchLabel: "Brutus-GTC6245/amman" → "amman"); otherwise fall back to a
|
|
242
|
+
// short id. An explicit name from the client wins untouched.
|
|
243
|
+
const wsName = opts.name?.trim() ||
|
|
244
|
+
(sourceBranch && branchLabel(sourceBranch)) ||
|
|
245
|
+
(prHead && branchLabel(prHead)) ||
|
|
246
|
+
`ws-${id.slice(0, 6)}`;
|
|
247
|
+
const gitCfg = this.appConfig.get().git;
|
|
248
|
+
// Three shapes: check out an existing PR's head branch, check out an existing
|
|
249
|
+
// branch, or fork a fresh one off the base.
|
|
250
|
+
const wt = prHead
|
|
251
|
+
? await createWorktreeFromPr(repo, id, sourcePr, prHead, branchBase)
|
|
252
|
+
: sourceBranch
|
|
253
|
+
? await createWorktreeFromExistingBranch(repo, id, sourceBranch, branchBase)
|
|
254
|
+
: await createWorktree(repo, id, wsName, branchBase, {
|
|
255
|
+
type: gitCfg.branchPrefixType,
|
|
256
|
+
custom: gitCfg.branchPrefix,
|
|
257
|
+
});
|
|
258
|
+
const record = {
|
|
259
|
+
id,
|
|
260
|
+
name: wsName,
|
|
261
|
+
projectId: project.id,
|
|
262
|
+
repo,
|
|
263
|
+
worktreePath: wt.worktreePath,
|
|
264
|
+
branch: wt.branch,
|
|
265
|
+
baseBranch: wt.baseBranch,
|
|
266
|
+
status: "idle",
|
|
267
|
+
model: opts.model,
|
|
268
|
+
};
|
|
269
|
+
this.workspaces.set(id, { record, terminals: new Map() });
|
|
270
|
+
await this.persist();
|
|
271
|
+
this.hub.broadcast("workspace.created", id, {
|
|
272
|
+
ws: id,
|
|
273
|
+
name: wsName,
|
|
274
|
+
branch: wt.branch,
|
|
275
|
+
});
|
|
276
|
+
this.events?.emitSystem({ kind: "workspace.created", ws: id, context: { name: wsName } });
|
|
277
|
+
// "Create worktree from an issue": kick the agent off with the confirmed
|
|
278
|
+
// issue as its first task, now that the worktree exists.
|
|
279
|
+
const initialPrompt = opts.initialPrompt?.trim();
|
|
280
|
+
if (initialPrompt)
|
|
281
|
+
this.prompt(id, initialPrompt);
|
|
282
|
+
return record;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Create a repo-less "chat" workspace — a free-form conversation with the
|
|
286
|
+
* agent, not tied to any GitHub project or git worktree (the Claude/ChatGPT
|
|
287
|
+
* desktop model). It starts named "New Chat"; the first prompt triggers an
|
|
288
|
+
* auto-title (see `prompt`). The agent still gets a real cwd — a private
|
|
289
|
+
* scratch dir under `~/.immediacy/chats/<id>` — so file/bash tools work,
|
|
290
|
+
* scoped to that folder by the usual workspace-scope rule.
|
|
291
|
+
*/
|
|
292
|
+
async createChat(opts = {}) {
|
|
293
|
+
const id = randomUUID();
|
|
294
|
+
const scratch = join(CHATS_DIR, id);
|
|
295
|
+
await mkdir(scratch, { recursive: true });
|
|
296
|
+
const record = {
|
|
297
|
+
id,
|
|
298
|
+
name: "New Chat",
|
|
299
|
+
kind: "chat",
|
|
300
|
+
projectId: "",
|
|
301
|
+
repo: scratch,
|
|
302
|
+
worktreePath: scratch,
|
|
303
|
+
branch: "",
|
|
304
|
+
baseBranch: "",
|
|
305
|
+
status: "idle",
|
|
306
|
+
model: opts.model,
|
|
307
|
+
};
|
|
308
|
+
this.workspaces.set(id, { record, terminals: new Map() });
|
|
309
|
+
await this.persist();
|
|
310
|
+
this.hub.broadcast("workspace.created", id, {
|
|
311
|
+
ws: id,
|
|
312
|
+
name: record.name,
|
|
313
|
+
branch: "",
|
|
314
|
+
kind: "chat",
|
|
315
|
+
});
|
|
316
|
+
this.events?.emitSystem({ kind: "workspace.created", ws: id, context: { name: record.name } });
|
|
317
|
+
return record;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Rename a workspace and fan the new label out to every client. Used by the
|
|
321
|
+
* chat auto-title flow; the sidebar row updates in place without a full list
|
|
322
|
+
* refresh.
|
|
323
|
+
*/
|
|
324
|
+
async renameWorkspace(id, name) {
|
|
325
|
+
const live = this.workspaces.get(id);
|
|
326
|
+
if (!live || !name.trim())
|
|
327
|
+
return;
|
|
328
|
+
live.record.name = name.trim();
|
|
329
|
+
await this.persist();
|
|
330
|
+
this.hub.broadcast("workspace.renamed", id, { ws: id, name: live.record.name });
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Summarize a chat's first prompt into a short title and rename the workspace.
|
|
334
|
+
* Fire-and-forget: guards against a racing second prompt (only renames while
|
|
335
|
+
* the label is still the placeholder) and swallows any failure.
|
|
336
|
+
*/
|
|
337
|
+
async autoTitleChat(id, firstPrompt) {
|
|
338
|
+
const sdkEnv = envForKey("ANTHROPIC_API_KEY", this.credentials.getKey("anthropic"));
|
|
339
|
+
const title = await generateChatTitle(firstPrompt, sdkEnv);
|
|
340
|
+
if (!title)
|
|
341
|
+
return;
|
|
342
|
+
const live = this.workspaces.get(id);
|
|
343
|
+
// Re-check: the user (or a prior title) may have renamed it meanwhile.
|
|
344
|
+
if (!live || live.record.kind !== "chat" || live.record.name !== "New Chat")
|
|
345
|
+
return;
|
|
346
|
+
await this.renameWorkspace(id, title);
|
|
347
|
+
}
|
|
348
|
+
/** Added/removed line totals vs base, per workspace (for the sidebar rows). */
|
|
349
|
+
async diffStat(id) {
|
|
350
|
+
const live = this.workspaces.get(id);
|
|
351
|
+
if (!live)
|
|
352
|
+
return null;
|
|
353
|
+
if (live.record.kind === "chat")
|
|
354
|
+
return { added: 0, removed: 0, filesChanged: 0 };
|
|
355
|
+
// Freshen origin first: the stat is measured against merge-base(origin/<base>,
|
|
356
|
+
// HEAD), so a stale remote-tracking ref regresses that merge-base and inflates
|
|
357
|
+
// the counts by sweeping in commits the branch only merged, not authored (the
|
|
358
|
+
// +2.7k-vs-+425 drift). Throttled + coalesced per repo, so it's a no-op when a
|
|
359
|
+
// recent poll already fetched. See diffBaseRef in worktree.ts.
|
|
360
|
+
await maybeSyncRepo(live.record.repo);
|
|
361
|
+
return diffStat(live.record.worktreePath, live.record.baseBranch);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* The sidebar summaries: one row per workspace with its live diff-stat. Diffs
|
|
365
|
+
* are measured in parallel and best-effort (0/0 on any git hiccup); `added`/
|
|
366
|
+
* `removed` are omitted when zero so a clean worktree renders no counter.
|
|
367
|
+
*/
|
|
368
|
+
async summaries() {
|
|
369
|
+
const list = this.list();
|
|
370
|
+
// Same freshness requirement as diffStat/diff: the per-row counts are diffed
|
|
371
|
+
// against merge-base(origin/<base>, HEAD), so a stale origin inflates them.
|
|
372
|
+
// Freshen each distinct base repo before measuring — throttled + coalesced per
|
|
373
|
+
// repo, so a workspace-heavy account still costs at most one fetch per repo per
|
|
374
|
+
// interval, and it's a no-op when a recent poll already fetched.
|
|
375
|
+
// Chats have no git repo — only freshen the worktree-backed ones.
|
|
376
|
+
await Promise.all([...new Set(list.filter((r) => r.kind !== "chat").map((r) => r.repo))].map((repo) => maybeSyncRepo(repo)));
|
|
377
|
+
return Promise.all(list.map(async (r) => {
|
|
378
|
+
// A chat has no diff or origin — emit a bare row carrying only its kind.
|
|
379
|
+
if (r.kind === "chat") {
|
|
380
|
+
return {
|
|
381
|
+
ws: r.id,
|
|
382
|
+
name: r.name,
|
|
383
|
+
branch: r.branch,
|
|
384
|
+
status: r.status,
|
|
385
|
+
projectId: r.projectId,
|
|
386
|
+
kind: "chat",
|
|
387
|
+
...(r.model ? { model: r.model } : {}),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const stat = (await diffStat(r.worktreePath, r.baseBranch)) ??
|
|
391
|
+
{ added: 0, removed: 0, filesChanged: 0 };
|
|
392
|
+
const repoUrl = await originUrl(r.repo);
|
|
393
|
+
return {
|
|
394
|
+
ws: r.id,
|
|
395
|
+
name: r.name,
|
|
396
|
+
branch: r.branch,
|
|
397
|
+
status: r.status,
|
|
398
|
+
projectId: r.projectId,
|
|
399
|
+
model: r.model,
|
|
400
|
+
...(stat.added ? { added: stat.added } : {}),
|
|
401
|
+
...(stat.removed ? { removed: stat.removed } : {}),
|
|
402
|
+
...(stat.filesChanged ? { filesChanged: stat.filesChanged } : {}),
|
|
403
|
+
...(repoUrl ? { repoUrl } : {}),
|
|
404
|
+
};
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Recompute the sidebar summaries and fan them out to every client — but only
|
|
409
|
+
* when something actually changed since the last fan-out. This is the single
|
|
410
|
+
* entry point for keeping the left-pane rows (diff-stat counters, name,
|
|
411
|
+
* branch, status) live: callers fire it whenever a worktree's state may have
|
|
412
|
+
* moved (an agent turn finished, files changed, the base advanced, a branch or
|
|
413
|
+
* PR was renamed) without having to know whether the numbers really shifted.
|
|
414
|
+
* The change-check keeps a frequently-polled path (git.status) from spamming
|
|
415
|
+
* identical lists at every connected device.
|
|
416
|
+
*/
|
|
417
|
+
async broadcastWorkspaceList() {
|
|
418
|
+
const list = await this.summaries();
|
|
419
|
+
const json = JSON.stringify(list);
|
|
420
|
+
if (json === this.lastListJson)
|
|
421
|
+
return; // nothing moved — skip the fan-out
|
|
422
|
+
this.lastListJson = json;
|
|
423
|
+
this.hub.broadcast("workspace.list.result", null, { workspaces: list });
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Freshen every base repo's remote-tracking refs, then reconcile the sidebar.
|
|
427
|
+
* This is the "an app just (re)connected — make the left pane reflect GitHub,
|
|
428
|
+
* not a snapshot from whenever we last fetched" path: the client's first
|
|
429
|
+
* `workspace.list` gets an immediate (locally-computed) reply for a snappy
|
|
430
|
+
* paint, and this runs alongside it so a since-pushed base lands a moment
|
|
431
|
+
* later. One fetch per distinct repo, throttled + coalesced per repo (see
|
|
432
|
+
* maybeSyncRepo), so a workspace-heavy account still costs one fetch per repo
|
|
433
|
+
* per interval; the broadcast's change-check no-ops when nothing moved.
|
|
434
|
+
* Best-effort — an offline/no-remote repo keeps its last-known refs.
|
|
435
|
+
*/
|
|
436
|
+
async syncAllReposAndRefresh() {
|
|
437
|
+
const repos = new Set(this.list().map((r) => r.repo));
|
|
438
|
+
await Promise.all([...repos].map((repo) => maybeSyncRepo(repo)));
|
|
439
|
+
await this.broadcastWorkspaceList();
|
|
440
|
+
}
|
|
441
|
+
async delete(id) {
|
|
442
|
+
const live = this.workspaces.get(id);
|
|
443
|
+
if (!live)
|
|
444
|
+
return;
|
|
445
|
+
// A chat has no worktree/branch to preserve — tear down its scratch dir.
|
|
446
|
+
if (live.record.kind === "chat") {
|
|
447
|
+
await this.deleteChat(live);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
await ensureCleanWorktree(live.record.worktreePath);
|
|
451
|
+
await live.agent?.stop();
|
|
452
|
+
for (const dt of live.terminals.values())
|
|
453
|
+
dt.terminal?.dispose();
|
|
454
|
+
// Soft delete: remove the worktree, keep the branch + base repo (§11).
|
|
455
|
+
await removeWorktree(live.record.repo, live.record.worktreePath);
|
|
456
|
+
this.workspaces.delete(id);
|
|
457
|
+
await this.chatlog.remove(id);
|
|
458
|
+
await this.persist();
|
|
459
|
+
}
|
|
460
|
+
/** Tear down a repo-less chat: stop the agent, drop its scratch dir + log. */
|
|
461
|
+
async deleteChat(live) {
|
|
462
|
+
const id = live.record.id;
|
|
463
|
+
await live.agent?.stop();
|
|
464
|
+
for (const dt of live.terminals.values())
|
|
465
|
+
dt.terminal?.dispose();
|
|
466
|
+
await rm(live.record.worktreePath, { recursive: true, force: true }).catch(() => { });
|
|
467
|
+
this.workspaces.delete(id);
|
|
468
|
+
await this.chatlog.remove(id);
|
|
469
|
+
await this.persist();
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Archive a workspace: a deliberate teardown that force-removes the worktree
|
|
473
|
+
* (discarding any uncommitted changes) and — only when
|
|
474
|
+
* git.deleteBranchOnArchive is set — deletes the *local* branch. The remote
|
|
475
|
+
* (pushed branch, PR, commits on origin) is never touched, so archiving a
|
|
476
|
+
* merged workspace is non-destructive to the shipped work. Distinct from
|
|
477
|
+
* delete, which always preserves the branch (§11).
|
|
478
|
+
*/
|
|
479
|
+
async archive(id) {
|
|
480
|
+
const live = this.workspaces.get(id);
|
|
481
|
+
if (!live)
|
|
482
|
+
return;
|
|
483
|
+
// A chat has no worktree/branch — archive is the same teardown as delete.
|
|
484
|
+
if (live.record.kind === "chat") {
|
|
485
|
+
await this.deleteChat(live);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const { repo, worktreePath, branch } = live.record;
|
|
489
|
+
await live.agent?.stop();
|
|
490
|
+
for (const dt of live.terminals.values())
|
|
491
|
+
dt.terminal?.dispose();
|
|
492
|
+
await removeWorktree(repo, worktreePath, /* force */ true);
|
|
493
|
+
if (this.appConfig.get().git.deleteBranchOnArchive) {
|
|
494
|
+
await deleteLocalBranch(repo, branch);
|
|
495
|
+
}
|
|
496
|
+
this.workspaces.delete(id);
|
|
497
|
+
await this.chatlog.remove(id);
|
|
498
|
+
await this.persist();
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Run a prompt-backed agent action (code review, create PR, fix errors,
|
|
502
|
+
* resolve merge conflicts, rename branch). Composes the built-in instruction
|
|
503
|
+
* plus the user's matching prompts.* custom text, then injects it through the
|
|
504
|
+
* normal prompt path — the agent does the work with its own permission-gated
|
|
505
|
+
* tools. Returns false when the workspace is unknown.
|
|
506
|
+
*/
|
|
507
|
+
runAction(id, kind) {
|
|
508
|
+
if (!this.workspaces.has(id))
|
|
509
|
+
return false;
|
|
510
|
+
this.prompt(id, composeActionPrompt(kind, this.appConfig.get().prompts));
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Mass-delete worktrees and/or whole projects. Selected projects contribute
|
|
515
|
+
* all of their remaining worktrees to the removal set (a project can't be
|
|
516
|
+
* deleted while any worktree still references it), then the projects
|
|
517
|
+
* themselves are removed. Each item is attempted independently: a dirty
|
|
518
|
+
* worktree that refuses removal is collected as a failure rather than
|
|
519
|
+
* aborting the rest — and its owning project then fails too (correctly, since
|
|
520
|
+
* it still has a worktree). Callers broadcast fresh lists afterwards.
|
|
521
|
+
*/
|
|
522
|
+
async deleteBatch(opts) {
|
|
523
|
+
const projectIds = new Set(opts.projects ?? []);
|
|
524
|
+
const wsIds = new Set(opts.workspaces ?? []);
|
|
525
|
+
// Fold in every worktree owned by a selected project.
|
|
526
|
+
for (const live of this.workspaces.values()) {
|
|
527
|
+
if (projectIds.has(live.record.projectId))
|
|
528
|
+
wsIds.add(live.record.id);
|
|
529
|
+
}
|
|
530
|
+
const deletedWorkspaces = [];
|
|
531
|
+
const deletedProjects = [];
|
|
532
|
+
const failures = [];
|
|
533
|
+
for (const id of wsIds) {
|
|
534
|
+
// Unknown/stale id (e.g. removed by another device between list and
|
|
535
|
+
// batch): skip it rather than report a phantom removal — delete() itself
|
|
536
|
+
// would no-op silently and we'd falsely count it as deleted.
|
|
537
|
+
if (!this.workspaces.has(id))
|
|
538
|
+
continue;
|
|
539
|
+
try {
|
|
540
|
+
await this.delete(id);
|
|
541
|
+
deletedWorkspaces.push(id);
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
failures.push({ id, kind: "workspace", message: errText(err) });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Projects last: their worktrees are gone (or failed, which will surface
|
|
548
|
+
// here as a "still has worktrees" project failure).
|
|
549
|
+
for (const id of projectIds) {
|
|
550
|
+
if (!this.projects.get(id))
|
|
551
|
+
continue; // unknown/stale project id: skip
|
|
552
|
+
try {
|
|
553
|
+
await this.deleteProject(id);
|
|
554
|
+
deletedProjects.push(id);
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
failures.push({ id, kind: "project", message: errText(err) });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return { deletedWorkspaces, deletedProjects, failures };
|
|
561
|
+
}
|
|
562
|
+
prompt(id, text, attachments) {
|
|
563
|
+
const live = this.workspaces.get(id);
|
|
564
|
+
if (!live) {
|
|
565
|
+
this.hub.broadcast("error", id, { code: "no_such_ws", message: "unknown workspace", ws: id });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!live.agent)
|
|
569
|
+
live.agent = this.spawnAgent(live);
|
|
570
|
+
// A fresh chat is "New Chat" until its first prompt; summarize that prompt
|
|
571
|
+
// into a short label (one cheap LLM call, best-effort, non-blocking).
|
|
572
|
+
if (live.record.kind === "chat" && live.record.name === "New Chat" && text.trim()) {
|
|
573
|
+
void this.autoTitleChat(id, text);
|
|
574
|
+
}
|
|
575
|
+
// Persist only attachment metadata (name + type) — never the bytes — so the
|
|
576
|
+
// transcript stays small and replay shows a chip without re-shipping files.
|
|
577
|
+
const attachmentMeta = attachments?.length
|
|
578
|
+
? attachments.map((a) => ({ name: a.name, mediaType: a.mediaType }))
|
|
579
|
+
: undefined;
|
|
580
|
+
void this.chatlog.append(id, {
|
|
581
|
+
ts: Date.now(),
|
|
582
|
+
role: "user",
|
|
583
|
+
text,
|
|
584
|
+
...(attachmentMeta ? { attachments: attachmentMeta } : {}),
|
|
585
|
+
});
|
|
586
|
+
// The workspace is working the moment we hand off the prompt. Publish
|
|
587
|
+
// `running` now — the agent only emits its own `running` once at spawn, so a
|
|
588
|
+
// resumed turn would otherwise stay stale at the previous turn's `done`
|
|
589
|
+
// until its first streamed chunk, leaving the client's working timer/badge
|
|
590
|
+
// dark until the first response lands.
|
|
591
|
+
this.setStatus(live, "running");
|
|
592
|
+
live.agent.prompt(text, attachments);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Interrupt the running agent turn. The session itself survives (its id is
|
|
596
|
+
* persisted), so the next prompt resumes the conversation in a fresh agent.
|
|
597
|
+
*/
|
|
598
|
+
async stopAgent(id) {
|
|
599
|
+
const live = this.workspaces.get(id);
|
|
600
|
+
if (!live?.agent)
|
|
601
|
+
return;
|
|
602
|
+
const agent = live.agent;
|
|
603
|
+
live.agent = undefined;
|
|
604
|
+
await agent.stop();
|
|
605
|
+
void this.chatlog.append(id, { ts: Date.now(), role: "system", text: "■ stopped" });
|
|
606
|
+
this.setStatus(live, "idle");
|
|
607
|
+
}
|
|
608
|
+
respondPermission(id, requestId, decision) {
|
|
609
|
+
this.workspaces.get(id)?.agent?.respondPermission(requestId, decision);
|
|
610
|
+
// The transcript records only the effect (allow_all lands as an allow); the
|
|
611
|
+
// durable rule itself lives in the allowlist store, not the chat log.
|
|
612
|
+
const resolved = decision === "deny" ? "deny" : "allow";
|
|
613
|
+
void this.chatlog.resolvePermission(id, requestId, resolved);
|
|
614
|
+
}
|
|
615
|
+
answerQuestion(id, requestId, answers, response) {
|
|
616
|
+
this.workspaces.get(id)?.agent?.answerQuestion(requestId, answers, response);
|
|
617
|
+
void this.chatlog.resolveQuestion(id, requestId, answers, response);
|
|
618
|
+
}
|
|
619
|
+
async diff(id) {
|
|
620
|
+
const live = this.workspaces.get(id);
|
|
621
|
+
if (!live)
|
|
622
|
+
return null;
|
|
623
|
+
if (live.record.kind === "chat")
|
|
624
|
+
return [];
|
|
625
|
+
// Freshen origin first, for the same reason as diffStat: a stale merge-base
|
|
626
|
+
// makes the file browser show every file the branch merely merged in, not just
|
|
627
|
+
// what it changed. Throttled + coalesced per repo (no-op right after a poll).
|
|
628
|
+
await maybeSyncRepo(live.record.repo);
|
|
629
|
+
return diffWorktree(live.record.worktreePath, live.record.baseBranch);
|
|
630
|
+
}
|
|
631
|
+
/** Full-codebase file listing for a workspace. */
|
|
632
|
+
async tree(id) {
|
|
633
|
+
const live = this.workspaces.get(id);
|
|
634
|
+
if (!live)
|
|
635
|
+
return null;
|
|
636
|
+
return listTree(live.record.worktreePath);
|
|
637
|
+
}
|
|
638
|
+
/** Read one worktree-relative file (for the in-app viewer). */
|
|
639
|
+
async readFile(id, path) {
|
|
640
|
+
const live = this.workspaces.get(id);
|
|
641
|
+
if (!live)
|
|
642
|
+
return null;
|
|
643
|
+
return readWorktreeFile(live.record.worktreePath, path);
|
|
644
|
+
}
|
|
645
|
+
/** Overwrite one worktree file (in-app editor save). Returns bytes written, or null for an unknown workspace. */
|
|
646
|
+
async writeFile(id, path, content) {
|
|
647
|
+
const live = this.workspaces.get(id);
|
|
648
|
+
if (!live)
|
|
649
|
+
return null;
|
|
650
|
+
return writeWorktreeFile(live.record.worktreePath, path, content);
|
|
651
|
+
}
|
|
652
|
+
/** Get (or create) a device's terminal slot, re-binding it to `socket`. */
|
|
653
|
+
deviceTerm(live, deviceId, socket) {
|
|
654
|
+
const dt = live.terminals.get(deviceId);
|
|
655
|
+
if (dt) {
|
|
656
|
+
// A reconnect (or just a fresh interaction) points the stream at the
|
|
657
|
+
// device's current socket so output follows it across reconnects.
|
|
658
|
+
dt.socket = socket;
|
|
659
|
+
return dt;
|
|
660
|
+
}
|
|
661
|
+
const fresh = { socket };
|
|
662
|
+
live.terminals.set(deviceId, fresh);
|
|
663
|
+
return fresh;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Dispose any terminal keyed by `deviceId` across every workspace. Called when
|
|
667
|
+
* a socket closes, passing that connection's fallback `connId`: a client that
|
|
668
|
+
* sends no stable deviceId gets a fresh connId per connection, so without this
|
|
669
|
+
* its PTY would leak on every reconnect. Terminals under a *stable* deviceId
|
|
670
|
+
* are intentionally left intact so their scrollback survives a reconnect.
|
|
671
|
+
*/
|
|
672
|
+
reapDevice(deviceId) {
|
|
673
|
+
for (const live of this.workspaces.values()) {
|
|
674
|
+
const dt = live.terminals.get(deviceId);
|
|
675
|
+
if (!dt)
|
|
676
|
+
continue;
|
|
677
|
+
dt.terminal?.dispose();
|
|
678
|
+
live.terminals.delete(deviceId);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
terminalInput(id, deviceId, data, socket) {
|
|
682
|
+
const live = this.workspaces.get(id);
|
|
683
|
+
if (!live)
|
|
684
|
+
return;
|
|
685
|
+
const dt = this.deviceTerm(live, deviceId, socket);
|
|
686
|
+
if (!dt.terminal || dt.terminal.isExited) {
|
|
687
|
+
dt.terminal = new Terminal(live.record.worktreePath,
|
|
688
|
+
// Stream ONLY to this device's socket — not a broadcast — so each
|
|
689
|
+
// device sees its own pty at its own size.
|
|
690
|
+
(d) => this.hub.sendTo(dt.socket, "terminal.data", id, { ws: id, data: d }), {
|
|
691
|
+
...dt.pendingSize,
|
|
692
|
+
// On shell exit, drop the dead PTY so the next keystroke respawns a
|
|
693
|
+
// fresh shell instead of writing to a closed process.
|
|
694
|
+
onExit: () => {
|
|
695
|
+
const l = this.workspaces.get(id);
|
|
696
|
+
const e = l?.terminals.get(deviceId);
|
|
697
|
+
if (e)
|
|
698
|
+
e.terminal = undefined;
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
dt.terminal.write(data);
|
|
703
|
+
}
|
|
704
|
+
terminalResize(id, deviceId, cols, rows, socket) {
|
|
705
|
+
const live = this.workspaces.get(id);
|
|
706
|
+
if (!live)
|
|
707
|
+
return;
|
|
708
|
+
const dt = this.deviceTerm(live, deviceId, socket);
|
|
709
|
+
// Remember the geometry even if the shell hasn't spawned yet, so the PTY
|
|
710
|
+
// comes up at the right size on first keystroke rather than 80x24. Only
|
|
711
|
+
// THIS device's pty is resized — a peer with a different screen is untouched.
|
|
712
|
+
dt.pendingSize = { cols, rows };
|
|
713
|
+
dt.terminal?.resize(cols, rows);
|
|
714
|
+
}
|
|
715
|
+
/** This device's retained PTY scrollback ("" until it has started one). */
|
|
716
|
+
terminalSnapshot(id, deviceId, socket) {
|
|
717
|
+
const live = this.workspaces.get(id);
|
|
718
|
+
if (!live)
|
|
719
|
+
return null;
|
|
720
|
+
// Binds the stream to `socket` so live output resumes after a reconnect,
|
|
721
|
+
// even before the device sends its next keystroke.
|
|
722
|
+
const dt = this.deviceTerm(live, deviceId, socket);
|
|
723
|
+
return dt.terminal?.snapshot() ?? "";
|
|
724
|
+
}
|
|
725
|
+
async gitStatus(id, sync = false) {
|
|
726
|
+
const live = this.workspaces.get(id);
|
|
727
|
+
if (!live)
|
|
728
|
+
return null;
|
|
729
|
+
// Chats have no git — no status to report.
|
|
730
|
+
if (live.record.kind === "chat")
|
|
731
|
+
return null;
|
|
732
|
+
// Refresh the base repo's remote-tracking refs before measuring against
|
|
733
|
+
// them, so ahead/behind and the line counts reflect what's actually on the
|
|
734
|
+
// remote rather than a snapshot from whenever the repo was last fetched.
|
|
735
|
+
// Throttled + coalesced per base repo (see maybeSyncRepo): a project with N
|
|
736
|
+
// worktrees all polling still costs one fetch per interval, not N. `sync` is
|
|
737
|
+
// set when the client is opening/refreshing the worktree on purpose — it
|
|
738
|
+
// forces the fetch past the throttle so a just-pushed base is reflected on
|
|
739
|
+
// the click rather than up to an interval later.
|
|
740
|
+
await maybeSyncRepo(live.record.repo, { force: sync });
|
|
741
|
+
// Self-heal: the rename_branch action lets the agent rename its own branch,
|
|
742
|
+
// which would leave record.branch (used for push/PR) stale. Re-sync from the
|
|
743
|
+
// worktree's real HEAD on each status poll. When the branch was renamed to
|
|
744
|
+
// something other than what it was created as, the new name is the
|
|
745
|
+
// meaningful identity for this work, so track it as the sidebar label too
|
|
746
|
+
// (the created label was a starting placeholder). broadcastWorkspaceList
|
|
747
|
+
// below fans the rename out to every client.
|
|
748
|
+
const cur = await currentBranch(live.record.worktreePath);
|
|
749
|
+
if (cur && cur !== live.record.branch) {
|
|
750
|
+
live.record.branch = cur;
|
|
751
|
+
// Track the label to the branch (minus any owner/account prefix — see
|
|
752
|
+
// branchLabel) — but not to a bare "HEAD", which is what a detached
|
|
753
|
+
// checkout (mid-rebase/bisect) reports; that's a transient git state, not a
|
|
754
|
+
// rename the user should see in the sidebar.
|
|
755
|
+
if (cur !== "HEAD")
|
|
756
|
+
live.record.name = branchLabel(cur);
|
|
757
|
+
await this.persist();
|
|
758
|
+
}
|
|
759
|
+
// Auto-archive on merge: when enabled and the branch's PR has merged, tear
|
|
760
|
+
// the workspace down (honoring deleteBranchOnArchive) and report it gone so
|
|
761
|
+
// clients drop it. Gated on the flag so the extra `gh` call only runs for
|
|
762
|
+
// opted-in workspaces. Returns null → the git.status handler re-broadcasts
|
|
763
|
+
// the workspace list.
|
|
764
|
+
if (this.appConfig.get().git.archiveOnMerge && (await detectMerged(live.record))) {
|
|
765
|
+
await this.archive(id);
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
const result = await gitStatus(live.record);
|
|
769
|
+
// Always match the label to the remote: once a branch has a PR (open or
|
|
770
|
+
// merged), that PR's title is the human-facing identity this work lands as,
|
|
771
|
+
// so adopt it as the sidebar label. This forward-heals a label that was
|
|
772
|
+
// previously set to the full branch, and keeps a friendly creation name in
|
|
773
|
+
// sync with the PR the user maintains on GitHub. Reuses the prInfo cache
|
|
774
|
+
// gitStatus just populated, so it costs no extra `gh` call.
|
|
775
|
+
if (result.hasPr || result.prMerged) {
|
|
776
|
+
const pr = await prInfo(live.record);
|
|
777
|
+
const label = pr.title ? pr.title.trim() : "";
|
|
778
|
+
if (label && label !== live.record.name) {
|
|
779
|
+
live.record.name = label;
|
|
780
|
+
await this.persist();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// This poll is the client's periodic "is anything new?" — the natural beat
|
|
784
|
+
// to reconcile the sidebar. It may have just renamed the label (above),
|
|
785
|
+
// freshened the base (maybeSyncRepo, so a since-landed Resolve/merge moved
|
|
786
|
+
// the fork point and the line counts with it), or simply be the first poll
|
|
787
|
+
// after edits that no other path fanned out. Push the fresh rows; the
|
|
788
|
+
// change-check no-ops when nothing actually moved, so a steady poll on an
|
|
789
|
+
// untouched worktree stays quiet.
|
|
790
|
+
void this.broadcastWorkspaceList();
|
|
791
|
+
return result;
|
|
792
|
+
}
|
|
793
|
+
async gitCommit(id, message) {
|
|
794
|
+
const live = this.workspaces.get(id);
|
|
795
|
+
if (!live)
|
|
796
|
+
return null;
|
|
797
|
+
const result = await gitCommit(live.record, message);
|
|
798
|
+
if (result.ok)
|
|
799
|
+
this.events?.emitSystem({ kind: "commit", ws: id });
|
|
800
|
+
return result;
|
|
801
|
+
}
|
|
802
|
+
async gitPush(id) {
|
|
803
|
+
const live = this.workspaces.get(id);
|
|
804
|
+
if (!live)
|
|
805
|
+
return null;
|
|
806
|
+
const result = await gitPush(live.record, this.appConfig.get().git.worktreePushAutoSetupRemote);
|
|
807
|
+
if (result.ok)
|
|
808
|
+
this.events?.emitSystem({ kind: "push", ws: id });
|
|
809
|
+
return result;
|
|
810
|
+
}
|
|
811
|
+
async gitResolve(id) {
|
|
812
|
+
const live = this.workspaces.get(id);
|
|
813
|
+
if (!live)
|
|
814
|
+
return null;
|
|
815
|
+
return gitResolve(live.record);
|
|
816
|
+
}
|
|
817
|
+
async gitMerge(id, action, paths) {
|
|
818
|
+
const live = this.workspaces.get(id);
|
|
819
|
+
if (!live)
|
|
820
|
+
return null;
|
|
821
|
+
return gitMerge(live.record, action, paths);
|
|
822
|
+
}
|
|
823
|
+
async prCreate(id, title, body) {
|
|
824
|
+
const live = this.workspaces.get(id);
|
|
825
|
+
if (!live)
|
|
826
|
+
return null;
|
|
827
|
+
const result = await prCreate(live.record, title, body);
|
|
828
|
+
if (result.ok) {
|
|
829
|
+
// The PR carries the meaningful name for this work — adopt its title as
|
|
830
|
+
// the sidebar label when it differs from whatever the worktree was created
|
|
831
|
+
// as, then fan the change out to every client.
|
|
832
|
+
const prTitle = title.trim();
|
|
833
|
+
if (prTitle && prTitle !== live.record.name) {
|
|
834
|
+
live.record.name = prTitle;
|
|
835
|
+
await this.persist();
|
|
836
|
+
void this.broadcastWorkspaceList();
|
|
837
|
+
}
|
|
838
|
+
this.events?.emitSystem({
|
|
839
|
+
kind: "pr.created",
|
|
840
|
+
ws: id,
|
|
841
|
+
context: result.url ? { url: result.url } : undefined,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
return result;
|
|
845
|
+
}
|
|
846
|
+
/** Merge the workspace branch's open PR on the remote (`gh pr merge`). */
|
|
847
|
+
async prMerge(id) {
|
|
848
|
+
const live = this.workspaces.get(id);
|
|
849
|
+
if (!live)
|
|
850
|
+
return null;
|
|
851
|
+
return prMerge(live.record);
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Open a GitHub issue from a workspace's output — the mirror of `prCreate`.
|
|
855
|
+
* The result rides the shared `git.result` channel (`op: "issue"`) so clients
|
|
856
|
+
* reuse their existing PR-create plumbing (busy state, result banner).
|
|
857
|
+
*/
|
|
858
|
+
async issueCreate(id, title, body) {
|
|
859
|
+
const live = this.workspaces.get(id);
|
|
860
|
+
if (!live)
|
|
861
|
+
return null;
|
|
862
|
+
const ws = live.record.id;
|
|
863
|
+
const trimmed = title.trim();
|
|
864
|
+
if (!trimmed) {
|
|
865
|
+
return { ws, op: "issue", ok: false, message: "issue title is empty" };
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
const url = await createIssue(live.record.worktreePath, trimmed, body ?? "");
|
|
869
|
+
this.events?.emitSystem({
|
|
870
|
+
kind: "issue.created",
|
|
871
|
+
ws: id,
|
|
872
|
+
context: url ? { url } : undefined,
|
|
873
|
+
});
|
|
874
|
+
return { ws, op: "issue", ok: true, message: "issue opened", url };
|
|
875
|
+
}
|
|
876
|
+
catch (err) {
|
|
877
|
+
return {
|
|
878
|
+
ws,
|
|
879
|
+
op: "issue",
|
|
880
|
+
ok: false,
|
|
881
|
+
message: err instanceof Error ? err.message : "creating issue failed",
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
async open(id, target) {
|
|
886
|
+
const live = this.workspaces.get(id);
|
|
887
|
+
if (!live)
|
|
888
|
+
return;
|
|
889
|
+
await openWorktree(live.record, target);
|
|
890
|
+
}
|
|
891
|
+
// --- internals ---
|
|
892
|
+
spawnAgent(live) {
|
|
893
|
+
// prompts.general is appended to every session's system prompt (empty →
|
|
894
|
+
// omitted, so no-config behavior is unchanged).
|
|
895
|
+
const general = this.appConfig.get().prompts.general.trim();
|
|
896
|
+
const ctx = {
|
|
897
|
+
workspaceId: live.record.id,
|
|
898
|
+
worktreePath: live.record.worktreePath,
|
|
899
|
+
resumeSessionId: live.record.sessionId,
|
|
900
|
+
model: live.record.model,
|
|
901
|
+
appendSystemPrompt: general || undefined,
|
|
902
|
+
allowlist: this.allowlist,
|
|
903
|
+
};
|
|
904
|
+
// AGENT_MODE=mock forces the scripted backend for every workspace (testing
|
|
905
|
+
// /CI, no credits). Otherwise the selected model's provider picks the
|
|
906
|
+
// backend: Claude via the Agent SDK, OpenAI via the Codex CLI, or an
|
|
907
|
+
// OpenRouter-hosted model via the same Agent SDK pointed at OpenRouter.
|
|
908
|
+
// Inferred as the union of the concrete classes (all EventEmitters), so
|
|
909
|
+
// `.on` is available without a cast; the return type narrows to Agent.
|
|
910
|
+
const provider = providerFor(live.record.model);
|
|
911
|
+
// Resolve the session's credential from the store (stored key → env → SSO):
|
|
912
|
+
// - codex/openai: inject OPENAI_API_KEY when present, else let the Codex CLI
|
|
913
|
+
// use its cached ChatGPT login.
|
|
914
|
+
// - claude/anthropic: inject ANTHROPIC_API_KEY when present, else let the
|
|
915
|
+
// Agent SDK use its cached Claude Code login. (undefined sdkEnv → no
|
|
916
|
+
// override, the native SSO path.)
|
|
917
|
+
// - openrouter: pass the resolved key straight through (no login fallback).
|
|
918
|
+
const agent = AGENT_MODE === "mock"
|
|
919
|
+
? new MockAgent(ctx)
|
|
920
|
+
: provider === "codex"
|
|
921
|
+
? new CodexAgent(ctx, envForKey("OPENAI_API_KEY", this.credentials.getKey("openai")))
|
|
922
|
+
: provider === "openrouter"
|
|
923
|
+
? new OpenRouterAgent(ctx, this.credentials.getKey("openrouter"))
|
|
924
|
+
: new ClaudeAgent(ctx, envForKey("ANTHROPIC_API_KEY", this.credentials.getKey("anthropic")));
|
|
925
|
+
agent.on("event", (e) => this.onAgentEvent(live, e));
|
|
926
|
+
return agent;
|
|
927
|
+
}
|
|
928
|
+
onAgentEvent(live, e) {
|
|
929
|
+
const id = live.record.id;
|
|
930
|
+
switch (e.kind) {
|
|
931
|
+
case "assistant":
|
|
932
|
+
this.hub.broadcast("event.assistant_message", id, { ws: id, text: e.text });
|
|
933
|
+
void this.chatlog.append(id, { ts: Date.now(), role: "assistant", text: e.text });
|
|
934
|
+
break;
|
|
935
|
+
case "tool_use":
|
|
936
|
+
this.hub.broadcast("event.tool_use", id, {
|
|
937
|
+
ws: id,
|
|
938
|
+
tool: e.tool,
|
|
939
|
+
inputSummary: e.inputSummary,
|
|
940
|
+
});
|
|
941
|
+
void this.chatlog.append(id, {
|
|
942
|
+
ts: Date.now(),
|
|
943
|
+
role: "tool",
|
|
944
|
+
text: `${e.tool}: ${e.inputSummary}`,
|
|
945
|
+
});
|
|
946
|
+
break;
|
|
947
|
+
case "permission":
|
|
948
|
+
this.hub.broadcast("event.permission_request", id, {
|
|
949
|
+
ws: id,
|
|
950
|
+
requestId: e.requestId,
|
|
951
|
+
tool: e.tool,
|
|
952
|
+
inputSummary: e.inputSummary,
|
|
953
|
+
});
|
|
954
|
+
void this.chatlog.append(id, {
|
|
955
|
+
ts: Date.now(),
|
|
956
|
+
role: "permission",
|
|
957
|
+
text: e.inputSummary,
|
|
958
|
+
requestId: e.requestId,
|
|
959
|
+
tool: e.tool,
|
|
960
|
+
});
|
|
961
|
+
break;
|
|
962
|
+
case "permission_resolved":
|
|
963
|
+
// A new "allow all" rule retroactively cleared this request — tell every
|
|
964
|
+
// client to collapse its card, and stamp the transcript so a reconnect
|
|
965
|
+
// (or another device) reflects the same outcome.
|
|
966
|
+
this.hub.broadcast("event.permission_resolved", id, {
|
|
967
|
+
ws: id,
|
|
968
|
+
requestId: e.requestId,
|
|
969
|
+
decision: e.decision,
|
|
970
|
+
});
|
|
971
|
+
void this.chatlog.resolvePermission(id, e.requestId, e.decision);
|
|
972
|
+
break;
|
|
973
|
+
case "ask_question":
|
|
974
|
+
this.hub.broadcast("event.ask_question", id, {
|
|
975
|
+
ws: id,
|
|
976
|
+
requestId: e.requestId,
|
|
977
|
+
questions: e.questions,
|
|
978
|
+
});
|
|
979
|
+
void this.chatlog.append(id, {
|
|
980
|
+
ts: Date.now(),
|
|
981
|
+
role: "question",
|
|
982
|
+
text: e.questions.map((q) => q.question).join(" · "),
|
|
983
|
+
requestId: e.requestId,
|
|
984
|
+
questions: e.questions,
|
|
985
|
+
});
|
|
986
|
+
break;
|
|
987
|
+
case "session":
|
|
988
|
+
live.record.sessionId = e.sessionId;
|
|
989
|
+
void this.persist();
|
|
990
|
+
this.hub.broadcast("event.session", id, {
|
|
991
|
+
ws: id,
|
|
992
|
+
sessionId: e.sessionId,
|
|
993
|
+
status: live.record.status,
|
|
994
|
+
});
|
|
995
|
+
break;
|
|
996
|
+
case "status":
|
|
997
|
+
this.setStatus(live, e.status);
|
|
998
|
+
break;
|
|
999
|
+
case "done": {
|
|
1000
|
+
const usageText = e.usage ? formatTurnUsage(e.usage) : undefined;
|
|
1001
|
+
this.hub.broadcast("event.agent_done", id, { ws: id, summary: e.summary, usageText });
|
|
1002
|
+
void this.chatlog.append(id, {
|
|
1003
|
+
ts: Date.now(),
|
|
1004
|
+
role: "system",
|
|
1005
|
+
text: `✓ done${e.summary ? ` — ${e.summary}` : ""}` +
|
|
1006
|
+
(usageText ? ` · ${usageText}` : ""),
|
|
1007
|
+
});
|
|
1008
|
+
// The turn almost certainly touched files, so the sidebar diff-stat is
|
|
1009
|
+
// now stale — recompute and fan the fresh counts out (no-ops if the turn
|
|
1010
|
+
// changed nothing). This keeps a background workspace's counter live
|
|
1011
|
+
// even when no client is polling its git.status.
|
|
1012
|
+
void this.broadcastWorkspaceList();
|
|
1013
|
+
// Reactive-trigger seam: an agent turn just finished in this workspace.
|
|
1014
|
+
this.events?.emitSystem({
|
|
1015
|
+
kind: "agent.done",
|
|
1016
|
+
ws: id,
|
|
1017
|
+
context: e.summary ? { summary: e.summary } : undefined,
|
|
1018
|
+
});
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
case "limit":
|
|
1022
|
+
// Surface the limit so a client can offer to schedule a restart job at
|
|
1023
|
+
// the reset time. Stamp the transcript so a reconnecting client still
|
|
1024
|
+
// sees it happened (the actionable card rides the live event below).
|
|
1025
|
+
this.hub.broadcast("event.session_limit", id, {
|
|
1026
|
+
ws: id,
|
|
1027
|
+
message: e.message,
|
|
1028
|
+
resetAt: e.resetAt,
|
|
1029
|
+
});
|
|
1030
|
+
// Persist as a `limit` entry (not a plain system line) so a reconnecting
|
|
1031
|
+
// client replays the actionable restart card, not just static text.
|
|
1032
|
+
void this.chatlog.append(id, {
|
|
1033
|
+
ts: Date.now(),
|
|
1034
|
+
role: "limit",
|
|
1035
|
+
text: e.message,
|
|
1036
|
+
resetAt: e.resetAt,
|
|
1037
|
+
});
|
|
1038
|
+
// Like `error`, a limit ends the turn without a `done`; finalize any
|
|
1039
|
+
// dispatched job run so a scheduled restart that re-hits the limit
|
|
1040
|
+
// doesn't leave a run (or ephemeral worktree) dangling.
|
|
1041
|
+
this.events?.emitSystem({ kind: "agent.error", ws: id, context: { error: e.message } });
|
|
1042
|
+
break;
|
|
1043
|
+
case "error":
|
|
1044
|
+
this.hub.broadcast("error", id, { code: "agent_error", message: e.message, ws: id });
|
|
1045
|
+
// A failed turn emits no `done`; tell the scheduler so a dispatched job
|
|
1046
|
+
// run finalizes (and its ephemeral worktree is not orphaned).
|
|
1047
|
+
this.events?.emitSystem({
|
|
1048
|
+
kind: "agent.error",
|
|
1049
|
+
ws: id,
|
|
1050
|
+
context: e.message ? { error: e.message } : undefined,
|
|
1051
|
+
});
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
setStatus(live, status) {
|
|
1056
|
+
live.record.status = status;
|
|
1057
|
+
const id = live.record.id;
|
|
1058
|
+
// status rides on event.session so the client updates the badge uniformly.
|
|
1059
|
+
this.hub.broadcast("event.session", id, {
|
|
1060
|
+
ws: id,
|
|
1061
|
+
sessionId: live.record.sessionId ?? "",
|
|
1062
|
+
status,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
async shutdown() {
|
|
1066
|
+
for (const live of this.workspaces.values()) {
|
|
1067
|
+
await live.agent?.stop();
|
|
1068
|
+
for (const dt of live.terminals.values())
|
|
1069
|
+
dt.terminal?.dispose();
|
|
1070
|
+
}
|
|
1071
|
+
await this.persist();
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* A one-key env override for an agent subprocess, or `undefined` when no key is
|
|
1076
|
+
* set — so the agent constructor leaves the environment untouched and its
|
|
1077
|
+
* backend falls back to its own cached login (the SSO path).
|
|
1078
|
+
*/
|
|
1079
|
+
function envForKey(name, key) {
|
|
1080
|
+
return key ? { [name]: key } : undefined;
|
|
1081
|
+
}
|
|
1082
|
+
/** Short human-readable reason from a thrown value, for batch-delete failures. */
|
|
1083
|
+
function errText(err) {
|
|
1084
|
+
return (err instanceof Error ? err.message : String(err)).slice(0, 500);
|
|
1085
|
+
}
|
|
1086
|
+
//# sourceMappingURL=workspace.js.map
|