office-core 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/.runtime-dist/scripts/bundle-host-package.js +46 -0
- package/.runtime-dist/scripts/demo-multi-agent.js +130 -0
- package/.runtime-dist/scripts/home-agent-host.js +1403 -0
- package/.runtime-dist/scripts/host-doctor.js +28 -0
- package/.runtime-dist/scripts/host-login.js +32 -0
- package/.runtime-dist/scripts/host-menu.js +227 -0
- package/.runtime-dist/scripts/host-open.js +20 -0
- package/.runtime-dist/scripts/install-host.js +108 -0
- package/.runtime-dist/scripts/lib/host-config.js +171 -0
- package/.runtime-dist/scripts/lib/local-runner.js +287 -0
- package/.runtime-dist/scripts/office-cli.js +698 -0
- package/.runtime-dist/scripts/run-local-project.js +277 -0
- package/.runtime-dist/src/auth/session-token.js +62 -0
- package/.runtime-dist/src/discord/outbox-ledger.js +56 -0
- package/.runtime-dist/src/do/AgentDO.js +205 -0
- package/.runtime-dist/src/do/GatewayShardDO.js +9 -0
- package/.runtime-dist/src/do/ProjectDO.js +829 -0
- package/.runtime-dist/src/do/TaskDO.js +356 -0
- package/.runtime-dist/src/index.js +123 -0
- package/.runtime-dist/src/project/office-view.js +405 -0
- package/.runtime-dist/src/project/read-model.js +79 -0
- package/.runtime-dist/src/routes/agents-bootstrap.js +9 -0
- package/.runtime-dist/src/routes/agents-descriptor.js +12 -0
- package/.runtime-dist/src/routes/agents-events.js +17 -0
- package/.runtime-dist/src/routes/agents-heartbeat.js +21 -0
- package/.runtime-dist/src/routes/agents-task-context.js +17 -0
- package/.runtime-dist/src/routes/bundles.js +198 -0
- package/.runtime-dist/src/routes/local-host.js +49 -0
- package/.runtime-dist/src/routes/projects.js +119 -0
- package/.runtime-dist/src/routes/tasks.js +67 -0
- package/.runtime-dist/src/task/reducer.js +464 -0
- package/.runtime-dist/src/types/project.js +1 -0
- package/.runtime-dist/src/types/protocol.js +3 -0
- package/.runtime-dist/src/types/runtime.js +1 -0
- package/README.md +148 -0
- package/bin/double-penetration-host.mjs +83 -0
- package/package.json +48 -0
- package/public/index.html +1581 -0
- package/public/install-host.ps1 +64 -0
- package/scripts/run-runtime-script.mjs +43 -0
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { probeRunnerAvailability, resolveRunnerCommand, } from "./lib/local-runner.js";
|
|
8
|
+
import { loadHostConfig, loadPersistedHostSessions, upsertHostConfig, savePersistedHostSessions, } from "./lib/host-config.js";
|
|
9
|
+
export const sessions = new Map();
|
|
10
|
+
let tickInFlight = false;
|
|
11
|
+
export const hooks = {
|
|
12
|
+
onRoomMessage: null,
|
|
13
|
+
onSessionChange: null,
|
|
14
|
+
};
|
|
15
|
+
export async function buildRuntimeConfig(argOverrides) {
|
|
16
|
+
const args = argOverrides ?? parseArgs(process.argv.slice(2));
|
|
17
|
+
const hostConfig = await loadHostConfig();
|
|
18
|
+
const baseUrl = (args.baseUrl ?? hostConfig?.base_url ?? "http://127.0.0.1:8787").replace(/\/$/, "");
|
|
19
|
+
const projectId = args.project ?? hostConfig?.project_id ?? "prj_local";
|
|
20
|
+
const hostId = args.hostId ?? hostConfig?.host_id ?? sanitizeId(`${os.hostname()}-${process.pid}`);
|
|
21
|
+
const displayName = args.displayName ?? hostConfig?.display_name ?? `${os.hostname()} host`;
|
|
22
|
+
const defaultWorkdir = path.resolve(args.workdir ?? hostConfig?.workdir ?? process.cwd());
|
|
23
|
+
const pollMs = normalizePollMs(args.pollMs ?? hostConfig?.poll_ms ?? 900);
|
|
24
|
+
const token = await ensureHostToken({
|
|
25
|
+
baseUrl,
|
|
26
|
+
projectId,
|
|
27
|
+
hostId,
|
|
28
|
+
displayName,
|
|
29
|
+
token: args.token ?? process.env.LOCAL_HOST_TOKEN ?? hostConfig?.token ?? "office-host-local-token",
|
|
30
|
+
workdir: defaultWorkdir,
|
|
31
|
+
pollMs,
|
|
32
|
+
});
|
|
33
|
+
const roomCursorSeq = args.project && args.project !== hostConfig?.project_id
|
|
34
|
+
? 0
|
|
35
|
+
: Number(args.roomCursorSeq ?? hostConfig?.room_cursor_seq ?? 0);
|
|
36
|
+
return { baseUrl, projectId, hostId, displayName, defaultWorkdir, pollMs, token, roomCursorSeq };
|
|
37
|
+
}
|
|
38
|
+
let _daemonInterval = null;
|
|
39
|
+
export async function startDaemonLoop(runtime) {
|
|
40
|
+
await restoreSessions(runtime);
|
|
41
|
+
await safeTick(runtime);
|
|
42
|
+
_daemonInterval = setInterval(() => {
|
|
43
|
+
if (tickInFlight) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
void safeTick(runtime);
|
|
47
|
+
}, runtime.pollMs);
|
|
48
|
+
}
|
|
49
|
+
export function stopDaemonLoop() {
|
|
50
|
+
if (_daemonInterval) {
|
|
51
|
+
clearInterval(_daemonInterval);
|
|
52
|
+
_daemonInterval = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const _isDirectRun = process.argv[1]?.replace(/\\/g, "/").includes("home-agent-host");
|
|
56
|
+
if (_isDirectRun) {
|
|
57
|
+
void (async () => {
|
|
58
|
+
const runtime = await buildRuntimeConfig();
|
|
59
|
+
console.log(`office host online: ${runtime.hostId}`);
|
|
60
|
+
console.log(`control plane: ${runtime.baseUrl}`);
|
|
61
|
+
console.log(`project: ${runtime.projectId}`);
|
|
62
|
+
console.log(`default workdir: ${runtime.defaultWorkdir}`);
|
|
63
|
+
const hostConfig = await loadHostConfig();
|
|
64
|
+
if (hostConfig)
|
|
65
|
+
console.log("host config: loaded from persisted machine config");
|
|
66
|
+
await startDaemonLoop(runtime);
|
|
67
|
+
})().catch((error) => {
|
|
68
|
+
console.error(error);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async function ensureHostToken(input) {
|
|
73
|
+
if (String(input.token || "").startsWith("hostsession.")) {
|
|
74
|
+
return input.token;
|
|
75
|
+
}
|
|
76
|
+
return registerHostToken(input);
|
|
77
|
+
}
|
|
78
|
+
async function registerHostToken(input) {
|
|
79
|
+
const enrollSecret = process.env.OFFICE_HOST_ENROLL_SECRET ??
|
|
80
|
+
process.env.DOUBLE_PENETRATION_ENROLL_SECRET ??
|
|
81
|
+
process.env.LOCAL_HOST_ENROLL_SECRET ??
|
|
82
|
+
"office-host-enroll-secret";
|
|
83
|
+
const response = await fetch(`${input.baseUrl}/api/projects/${input.projectId}/local-host/register`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"content-type": "application/json",
|
|
87
|
+
"x-enroll-secret": enrollSecret,
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
requested_host_id: input.hostId,
|
|
91
|
+
display_name: input.displayName,
|
|
92
|
+
machine_name: os.hostname(),
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new Error(`Host registration failed: ${response.status} ${await response.text()}`);
|
|
97
|
+
}
|
|
98
|
+
const registration = await response.json();
|
|
99
|
+
await upsertHostConfig({
|
|
100
|
+
host_id: registration.host_id,
|
|
101
|
+
display_name: input.displayName,
|
|
102
|
+
base_url: input.baseUrl,
|
|
103
|
+
project_id: registration.project_id,
|
|
104
|
+
workdir: input.workdir,
|
|
105
|
+
token: registration.host_token,
|
|
106
|
+
poll_ms: input.pollMs,
|
|
107
|
+
});
|
|
108
|
+
return registration.host_token;
|
|
109
|
+
}
|
|
110
|
+
async function safeTick(runtime) {
|
|
111
|
+
try {
|
|
112
|
+
await tick(runtime);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
console.error("[host-loop]", error instanceof Error ? error.message : String(error));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function tick(runtime) {
|
|
119
|
+
tickInFlight = true;
|
|
120
|
+
try {
|
|
121
|
+
await refreshSessions(runtime);
|
|
122
|
+
await processSessionReplies(runtime);
|
|
123
|
+
await heartbeat(runtime);
|
|
124
|
+
await processRoomMessages(runtime);
|
|
125
|
+
const commands = await pullCommands(runtime);
|
|
126
|
+
for (const command of commands) {
|
|
127
|
+
await handleCommand(runtime, command);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
tickInFlight = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
let cachedRunners = null;
|
|
135
|
+
let cachedEnvironment = null;
|
|
136
|
+
const PROBE_CACHE_TTL_MS = 30_000;
|
|
137
|
+
const ENV_CACHE_TTL_MS = 10_000;
|
|
138
|
+
async function heartbeat(runtime) {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
if (!cachedRunners || now - cachedRunners.ts > PROBE_CACHE_TTL_MS) {
|
|
141
|
+
const codex = probeRunnerAvailability("codex", process.env.CODEX_CMD);
|
|
142
|
+
const claude = probeRunnerAvailability("claude", process.env.CLAUDE_CMD);
|
|
143
|
+
cachedRunners = { codex: codex.available, claude: claude.available, ts: now };
|
|
144
|
+
}
|
|
145
|
+
const activeWorkdir = resolveProjectWorkdir(runtime.defaultWorkdir, runtime.projectId);
|
|
146
|
+
await mkdir(activeWorkdir, { recursive: true });
|
|
147
|
+
if (!cachedEnvironment || now - cachedEnvironment.ts > ENV_CACHE_TTL_MS) {
|
|
148
|
+
cachedEnvironment = { value: collectEnvironment(activeWorkdir), ts: now };
|
|
149
|
+
}
|
|
150
|
+
const environment = cachedEnvironment.value;
|
|
151
|
+
await postJson(runtime, `/api/projects/${runtime.projectId}/local-host/heartbeat`, {
|
|
152
|
+
host_id: runtime.hostId,
|
|
153
|
+
display_name: runtime.displayName,
|
|
154
|
+
hostname: os.hostname(),
|
|
155
|
+
platform: `${process.platform}/${process.arch}`,
|
|
156
|
+
status: "online",
|
|
157
|
+
default_workdir: activeWorkdir,
|
|
158
|
+
last_seen_at: new Date().toISOString(),
|
|
159
|
+
available_runners: {
|
|
160
|
+
codex: cachedRunners.codex,
|
|
161
|
+
claude: cachedRunners.claude,
|
|
162
|
+
},
|
|
163
|
+
environment,
|
|
164
|
+
sessions: Array.from(sessions.values())
|
|
165
|
+
.sort((left, right) => right.launched_at.localeCompare(left.launched_at))
|
|
166
|
+
.slice(0, 20),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async function processRoomMessages(runtime) {
|
|
170
|
+
const result = await postJson(runtime, `/api/projects/${runtime.projectId}/local-host/room/pull`, {
|
|
171
|
+
host_id: runtime.hostId,
|
|
172
|
+
cursor_seq: runtime.roomCursorSeq,
|
|
173
|
+
}).catch(() => null);
|
|
174
|
+
const messages = Array.isArray(result?.messages) ? result.messages : [];
|
|
175
|
+
const settings = (result?.settings ?? { all_agents_listening: true, conductor_name: "You" });
|
|
176
|
+
const latestSeq = Number(result?.latest_seq ?? runtime.roomCursorSeq);
|
|
177
|
+
if (messages.length === 0) {
|
|
178
|
+
if (latestSeq > runtime.roomCursorSeq) {
|
|
179
|
+
runtime.roomCursorSeq = latestSeq;
|
|
180
|
+
await persistRoomCursor(runtime);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const runnableSessions = Array.from(sessions.values()).filter((session) => session.status === "running");
|
|
185
|
+
const project = await getJson(runtime, `/api/projects/${runtime.projectId}`).catch(() => null);
|
|
186
|
+
for (const message of messages) {
|
|
187
|
+
hooks.onRoomMessage?.(message, runtime);
|
|
188
|
+
if (message.author_type === "system") {
|
|
189
|
+
runtime.roomCursorSeq = Math.max(runtime.roomCursorSeq, message.seq);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const targetSessions = runnableSessions.filter((session) => shouldSessionReceiveMessage(runtime, session, message, settings));
|
|
193
|
+
if (targetSessions.length === 0) {
|
|
194
|
+
console.log(`[room] seq=${message.seq} no sessions matched (runnable=${runnableSessions.length}), skipping`);
|
|
195
|
+
runtime.roomCursorSeq = Math.max(runtime.roomCursorSeq, message.seq);
|
|
196
|
+
await persistRoomCursor(runtime);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
console.log(`[room] seq=${message.seq} queueing for ${targetSessions.map((s) => s.agent_id).join(", ")}`);
|
|
200
|
+
for (const session of targetSessions) {
|
|
201
|
+
await refreshSessionSummary(runtime, session, project).catch(() => undefined);
|
|
202
|
+
await enqueueRoomPrompt(runtime, session, message, messages, settings);
|
|
203
|
+
}
|
|
204
|
+
runtime.roomCursorSeq = Math.max(runtime.roomCursorSeq, message.seq);
|
|
205
|
+
await persistRoomCursor(runtime);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function pullCommands(runtime) {
|
|
209
|
+
const result = await postJson(runtime, `/api/projects/${runtime.projectId}/local-host/commands/pull`, {
|
|
210
|
+
host_id: runtime.hostId,
|
|
211
|
+
});
|
|
212
|
+
return Array.isArray(result?.commands) ? result.commands : [];
|
|
213
|
+
}
|
|
214
|
+
async function handleCommand(runtime, command) {
|
|
215
|
+
try {
|
|
216
|
+
if (command.command_type === "switch_project") {
|
|
217
|
+
const previousRuntime = { ...runtime };
|
|
218
|
+
await switchProject(runtime, command);
|
|
219
|
+
await postJson(previousRuntime, `/api/projects/${previousRuntime.projectId}/local-host/commands/complete`, {
|
|
220
|
+
host_id: previousRuntime.hostId,
|
|
221
|
+
command_id: command.command_id,
|
|
222
|
+
status: "completed",
|
|
223
|
+
note: `Switched to ${runtime.projectId}.`,
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (command.command_type === "remove") {
|
|
228
|
+
const sessionId = String(command.session_id ?? "").trim();
|
|
229
|
+
if (!sessionId) {
|
|
230
|
+
throw new Error("Missing session_id for remove command");
|
|
231
|
+
}
|
|
232
|
+
const removed = await stopSession(runtime, sessionId);
|
|
233
|
+
await postJson(runtime, `/api/projects/${runtime.projectId}/local-host/commands/complete`, {
|
|
234
|
+
host_id: runtime.hostId,
|
|
235
|
+
command_id: command.command_id,
|
|
236
|
+
status: "completed",
|
|
237
|
+
session_id: removed?.session_id ?? sessionId,
|
|
238
|
+
note: removed ? `Removed ${removed.agent_id}.` : "Session already gone.",
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const context = await buildSpawnContext(runtime, command);
|
|
243
|
+
const session = await spawnInteractiveSession(runtime, command, context);
|
|
244
|
+
sessions.set(session.session_id, session);
|
|
245
|
+
await persistSessions();
|
|
246
|
+
await postJson(runtime, `/api/projects/${runtime.projectId}/local-host/commands/complete`, {
|
|
247
|
+
host_id: runtime.hostId,
|
|
248
|
+
command_id: command.command_id,
|
|
249
|
+
status: "completed",
|
|
250
|
+
session_id: session.session_id,
|
|
251
|
+
note: `Launched ${command.runner} with office task context.`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
await postJson(runtime, `/api/projects/${runtime.projectId}/local-host/commands/complete`, {
|
|
256
|
+
host_id: runtime.hostId,
|
|
257
|
+
command_id: command.command_id,
|
|
258
|
+
status: "failed",
|
|
259
|
+
error: error instanceof Error ? error.message : String(error),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function switchProject(runtime, command) {
|
|
264
|
+
const targetProjectId = String(command.target_project_id ?? "").trim();
|
|
265
|
+
if (!targetProjectId) {
|
|
266
|
+
throw new Error("Missing target project for switch");
|
|
267
|
+
}
|
|
268
|
+
if (targetProjectId === runtime.projectId) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const previousRuntime = { ...runtime };
|
|
272
|
+
const stoppedSessions = Array.from(sessions.keys());
|
|
273
|
+
for (const sessionId of stoppedSessions) {
|
|
274
|
+
await stopSession(runtime, sessionId);
|
|
275
|
+
}
|
|
276
|
+
sessions.clear();
|
|
277
|
+
await persistSessions();
|
|
278
|
+
const previousWorkdir = resolveProjectWorkdir(previousRuntime.defaultWorkdir, previousRuntime.projectId);
|
|
279
|
+
await mkdir(previousWorkdir, { recursive: true }).catch(() => undefined);
|
|
280
|
+
await postJson(previousRuntime, `/api/projects/${previousRuntime.projectId}/local-host/heartbeat`, {
|
|
281
|
+
host_id: previousRuntime.hostId,
|
|
282
|
+
display_name: previousRuntime.displayName,
|
|
283
|
+
hostname: os.hostname(),
|
|
284
|
+
platform: `${process.platform}/${process.arch}`,
|
|
285
|
+
status: "offline",
|
|
286
|
+
default_workdir: previousWorkdir,
|
|
287
|
+
last_seen_at: new Date().toISOString(),
|
|
288
|
+
available_runners: {
|
|
289
|
+
codex: probeRunnerAvailability("codex", process.env.CODEX_CMD).available,
|
|
290
|
+
claude: probeRunnerAvailability("claude", process.env.CLAUDE_CMD).available,
|
|
291
|
+
},
|
|
292
|
+
environment: collectEnvironment(previousWorkdir),
|
|
293
|
+
sessions: [],
|
|
294
|
+
}).catch(() => undefined);
|
|
295
|
+
runtime.projectId = targetProjectId;
|
|
296
|
+
runtime.roomCursorSeq = 0;
|
|
297
|
+
runtime.token = await registerHostToken({
|
|
298
|
+
baseUrl: runtime.baseUrl,
|
|
299
|
+
projectId: runtime.projectId,
|
|
300
|
+
hostId: runtime.hostId,
|
|
301
|
+
displayName: runtime.displayName,
|
|
302
|
+
workdir: runtime.defaultWorkdir,
|
|
303
|
+
pollMs: runtime.pollMs,
|
|
304
|
+
});
|
|
305
|
+
const activeWorkdir = resolveProjectWorkdir(runtime.defaultWorkdir, runtime.projectId);
|
|
306
|
+
await mkdir(activeWorkdir, { recursive: true });
|
|
307
|
+
await upsertHostConfig({
|
|
308
|
+
host_id: runtime.hostId,
|
|
309
|
+
display_name: runtime.displayName,
|
|
310
|
+
base_url: runtime.baseUrl,
|
|
311
|
+
project_id: runtime.projectId,
|
|
312
|
+
workdir: runtime.defaultWorkdir,
|
|
313
|
+
token: runtime.token,
|
|
314
|
+
room_cursor_seq: runtime.roomCursorSeq,
|
|
315
|
+
poll_ms: runtime.pollMs,
|
|
316
|
+
});
|
|
317
|
+
await heartbeat(runtime);
|
|
318
|
+
}
|
|
319
|
+
function shouldSessionReceiveMessage(runtime, session, message, settings) {
|
|
320
|
+
const explicitlyTargeted = Boolean(message.target_host_ids?.length || message.target_session_ids?.length || message.target_agent_ids?.length);
|
|
321
|
+
if (message.author_type !== "user" && !explicitlyTargeted) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
if (message.author_type === "agent" && message.session_id && message.session_id === session.session_id) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
if (message.target_host_ids?.length && !message.target_host_ids.includes(runtime.hostId)) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
if (message.target_session_ids?.length) {
|
|
331
|
+
return message.target_session_ids.includes(session.session_id);
|
|
332
|
+
}
|
|
333
|
+
if (message.target_agent_ids?.length) {
|
|
334
|
+
return message.target_agent_ids.includes(session.agent_id);
|
|
335
|
+
}
|
|
336
|
+
return message.author_type === "user" && settings.all_agents_listening;
|
|
337
|
+
}
|
|
338
|
+
async function enqueueRoomPrompt(runtime, session, message, recentMessages, settings) {
|
|
339
|
+
if (!session.inbox_dir) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const contextMessages = recentMessages
|
|
343
|
+
.filter((entry) => entry.seq < message.seq && entry.author_type !== "system")
|
|
344
|
+
.slice(-8)
|
|
345
|
+
.map((entry) => ({
|
|
346
|
+
seq: entry.seq,
|
|
347
|
+
author_type: entry.author_type,
|
|
348
|
+
author_label: entry.author_label,
|
|
349
|
+
text: entry.text,
|
|
350
|
+
}));
|
|
351
|
+
const payload = {
|
|
352
|
+
type: "room_message",
|
|
353
|
+
message_id: message.message_id,
|
|
354
|
+
seq: message.seq,
|
|
355
|
+
author_type: message.author_type,
|
|
356
|
+
author_label: message.author_label,
|
|
357
|
+
text: message.text,
|
|
358
|
+
task_id: message.task_id ?? session.task_id ?? null,
|
|
359
|
+
reply_to_seq: message.reply_to_seq ?? null,
|
|
360
|
+
settings: {
|
|
361
|
+
all_agents_listening: settings.all_agents_listening,
|
|
362
|
+
conductor_name: settings.conductor_name,
|
|
363
|
+
},
|
|
364
|
+
recent_messages: contextMessages,
|
|
365
|
+
};
|
|
366
|
+
const inboxFile = path.join(session.inbox_dir, `${String(message.seq).padStart(8, "0")}-${sanitizeId(message.message_id)}.json`);
|
|
367
|
+
await writeFile(inboxFile, JSON.stringify(payload, null, 2), "utf8");
|
|
368
|
+
}
|
|
369
|
+
async function processSessionReplies(runtime) {
|
|
370
|
+
for (const session of sessions.values()) {
|
|
371
|
+
if (session.status !== "running" || !session.outbox_dir) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
let files = [];
|
|
375
|
+
try {
|
|
376
|
+
files = (await readdir(session.outbox_dir))
|
|
377
|
+
.filter((name) => name.toLowerCase().endsWith(".json"))
|
|
378
|
+
.sort((left, right) => left.localeCompare(right));
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
for (const name of files) {
|
|
384
|
+
const replyPath = path.join(session.outbox_dir, name);
|
|
385
|
+
console.log(`[outbox] discovered ${replyPath}`);
|
|
386
|
+
let shouldRemove = false;
|
|
387
|
+
try {
|
|
388
|
+
const raw = await readFile(replyPath, "utf8");
|
|
389
|
+
const reply = JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
390
|
+
console.log(`[outbox] parsed ${name}: ${JSON.stringify(reply)}`);
|
|
391
|
+
await postRoomMessageUpsert(runtime, {
|
|
392
|
+
message_id: String(reply.message_id ?? `msg_${crypto.randomUUID()}`),
|
|
393
|
+
author_type: reply.author_type === "system" ? "system" : "agent",
|
|
394
|
+
author_id: String(reply.author_id ?? session.agent_id),
|
|
395
|
+
author_label: String(reply.author_label ?? session.agent_id),
|
|
396
|
+
text: String(reply.text ?? "").trim(),
|
|
397
|
+
task_id: reply.task_id ? String(reply.task_id) : session.task_id ?? null,
|
|
398
|
+
host_id: runtime.hostId,
|
|
399
|
+
session_id: session.session_id,
|
|
400
|
+
reply_to_seq: Number.isFinite(Number(reply.reply_to_seq)) ? Number(reply.reply_to_seq) : null,
|
|
401
|
+
});
|
|
402
|
+
console.log(`[outbox] room upsert success ${name}`);
|
|
403
|
+
shouldRemove = true;
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
console.log(`[outbox] room upsert failed ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
if (shouldRemove) {
|
|
410
|
+
await unlink(replyPath).catch(() => undefined);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
export async function buildSpawnContext(runtime, command) {
|
|
417
|
+
const workdir = path.resolve(command.workdir || resolveProjectWorkdir(runtime.defaultWorkdir, runtime.projectId));
|
|
418
|
+
await mkdir(workdir, { recursive: true });
|
|
419
|
+
const environment = collectEnvironment(workdir);
|
|
420
|
+
const [project, task, bootstrap] = await Promise.all([
|
|
421
|
+
getJson(runtime, `/api/projects/${runtime.projectId}`).catch(() => null),
|
|
422
|
+
command.task_id ? getJson(runtime, `/api/tasks/${command.task_id}`).catch(() => null) : Promise.resolve(null),
|
|
423
|
+
bootstrapAgent(runtime, command).catch(() => null),
|
|
424
|
+
]);
|
|
425
|
+
if (bootstrap && command.task_id && task?.snapshot) {
|
|
426
|
+
await sendSpawnedAgentStatus(runtime, command, bootstrap, task, "Spawned locally and joining the task workspace.").catch(() => undefined);
|
|
427
|
+
}
|
|
428
|
+
const onboardingText = buildOnboardingText();
|
|
429
|
+
const summaryText = buildSummaryText(runtime, command, environment, project, task, bootstrap);
|
|
430
|
+
const initialPrompt = buildInteractivePrompt(runtime, command, environment, task);
|
|
431
|
+
return {
|
|
432
|
+
environment,
|
|
433
|
+
project,
|
|
434
|
+
task,
|
|
435
|
+
bootstrap,
|
|
436
|
+
onboardingText,
|
|
437
|
+
summaryText,
|
|
438
|
+
initialPrompt,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
async function bootstrapAgent(runtime, command) {
|
|
442
|
+
return postJson(runtime, "/api/agents/bootstrap", {
|
|
443
|
+
agent_id: command.agent_id,
|
|
444
|
+
display_name: command.agent_id,
|
|
445
|
+
workspace_id: "ws_local",
|
|
446
|
+
active_project_id: runtime.projectId,
|
|
447
|
+
active_task_id: command.task_id ?? null,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
async function sendSpawnedAgentStatus(runtime, command, bootstrap, task, summary) {
|
|
451
|
+
if (!command.task_id || !task.snapshot) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const envelope = {
|
|
455
|
+
protocol_version: "1.0",
|
|
456
|
+
command_schema_version: 1,
|
|
457
|
+
command_id: `${command.agent_id}:status.upsert:${crypto.randomUUID()}`,
|
|
458
|
+
session_id: bootstrap.fence.session_id,
|
|
459
|
+
session_epoch: bootstrap.fence.session_epoch,
|
|
460
|
+
agent_id: command.agent_id,
|
|
461
|
+
task_id: command.task_id,
|
|
462
|
+
observed_task_version: task.snapshot.task_version,
|
|
463
|
+
bundle_seq: bootstrap.descriptor.current_manifest_seq ?? task.snapshot.manifest_seq ?? 1,
|
|
464
|
+
type: "status.upsert",
|
|
465
|
+
payload: {
|
|
466
|
+
task_summary: summary,
|
|
467
|
+
state: "active",
|
|
468
|
+
},
|
|
469
|
+
sent_at: new Date().toISOString(),
|
|
470
|
+
};
|
|
471
|
+
await postJson(runtime, "/api/agents/events", { commands: [envelope] }, bootstrap.session_token);
|
|
472
|
+
}
|
|
473
|
+
export async function spawnInteractiveSession(runtime, command, context) {
|
|
474
|
+
if (!command.runner) {
|
|
475
|
+
throw new Error("Missing runner for spawn command");
|
|
476
|
+
}
|
|
477
|
+
const runnerCommand = resolveRunnerCommand(command.runner, command.runner === "codex" ? process.env.CODEX_CMD : process.env.CLAUDE_CMD);
|
|
478
|
+
const session_id = `localsess_${crypto.randomUUID()}`;
|
|
479
|
+
const launched_at = new Date().toISOString();
|
|
480
|
+
const workdir = path.resolve(command.workdir || resolveProjectWorkdir(runtime.defaultWorkdir, runtime.projectId));
|
|
481
|
+
const scriptDir = await mkdtemp(path.join(safeScriptTempBase(), "office-host-"));
|
|
482
|
+
const scriptPath = path.join(scriptDir, `${session_id}.ps1`);
|
|
483
|
+
const pidPath = path.join(scriptDir, `${session_id}.pid`);
|
|
484
|
+
const contextRoot = path.join(workdir, ".office-host");
|
|
485
|
+
const roomDir = path.join(contextRoot, "rooms", sanitizeId(runtime.projectId));
|
|
486
|
+
const sessionDir = path.join(contextRoot, "sessions", sanitizeId(session_id));
|
|
487
|
+
const inboxDir = path.join(sessionDir, "inbox");
|
|
488
|
+
const outboxDir = path.join(sessionDir, "outbox");
|
|
489
|
+
const onboardingPath = path.join(contextRoot, "onboarding.txt");
|
|
490
|
+
const summaryPath = path.join(roomDir, "summary.txt");
|
|
491
|
+
await mkdir(roomDir, { recursive: true });
|
|
492
|
+
await mkdir(inboxDir, { recursive: true });
|
|
493
|
+
await mkdir(outboxDir, { recursive: true });
|
|
494
|
+
await writeFile(onboardingPath, context.onboardingText, "utf8");
|
|
495
|
+
await writeFile(summaryPath, context.summaryText, "utf8");
|
|
496
|
+
const scriptBody = buildPowerShellLauncher({
|
|
497
|
+
runner: command.runner,
|
|
498
|
+
runnerCommand,
|
|
499
|
+
agentId: command.agent_id,
|
|
500
|
+
mode: command.mode ?? "attach",
|
|
501
|
+
workdir,
|
|
502
|
+
contextDir: contextRoot,
|
|
503
|
+
onboardingPath,
|
|
504
|
+
summaryPath,
|
|
505
|
+
inboxDir,
|
|
506
|
+
outboxDir,
|
|
507
|
+
pidPath,
|
|
508
|
+
prompt: context.initialPrompt,
|
|
509
|
+
projectId: runtime.projectId,
|
|
510
|
+
taskId: command.task_id ?? null,
|
|
511
|
+
effort: command.effort ?? "high",
|
|
512
|
+
hostId: runtime.hostId,
|
|
513
|
+
hostDisplayName: runtime.displayName,
|
|
514
|
+
sessionId: session_id,
|
|
515
|
+
});
|
|
516
|
+
await writeFile(scriptPath, scriptBody, "utf8");
|
|
517
|
+
const child = spawn("wt.exe", [
|
|
518
|
+
"-w",
|
|
519
|
+
"0",
|
|
520
|
+
"new-tab",
|
|
521
|
+
"--title",
|
|
522
|
+
`office :: ${command.agent_id}`,
|
|
523
|
+
"--",
|
|
524
|
+
"powershell.exe",
|
|
525
|
+
"-NoProfile",
|
|
526
|
+
"-ExecutionPolicy",
|
|
527
|
+
"Bypass",
|
|
528
|
+
"-NoExit",
|
|
529
|
+
"-Command",
|
|
530
|
+
`& '${toPsSingleQuoted(scriptPath)}'`,
|
|
531
|
+
], {
|
|
532
|
+
cwd: workdir,
|
|
533
|
+
detached: true,
|
|
534
|
+
stdio: "ignore",
|
|
535
|
+
windowsHide: true,
|
|
536
|
+
});
|
|
537
|
+
const session = {
|
|
538
|
+
session_id,
|
|
539
|
+
project_id: runtime.projectId,
|
|
540
|
+
runner: command.runner,
|
|
541
|
+
agent_id: command.agent_id,
|
|
542
|
+
mode: command.mode ?? "attach",
|
|
543
|
+
workdir,
|
|
544
|
+
status: "running",
|
|
545
|
+
launched_at,
|
|
546
|
+
task_id: command.task_id ?? null,
|
|
547
|
+
note: context.initialPrompt,
|
|
548
|
+
effort: command.effort ?? "high",
|
|
549
|
+
shell_pid: await waitForPid(scriptPath, pidPath),
|
|
550
|
+
script_dir: scriptDir,
|
|
551
|
+
context_dir: contextRoot,
|
|
552
|
+
onboarding_path: onboardingPath,
|
|
553
|
+
summary_path: summaryPath,
|
|
554
|
+
inbox_dir: inboxDir,
|
|
555
|
+
outbox_dir: outboxDir,
|
|
556
|
+
bootstrap: context.bootstrap && context.task?.snapshot
|
|
557
|
+
? {
|
|
558
|
+
session_id: context.bootstrap.fence.session_id,
|
|
559
|
+
session_epoch: context.bootstrap.fence.session_epoch,
|
|
560
|
+
session_token: context.bootstrap.session_token,
|
|
561
|
+
bundle_seq: context.bootstrap.descriptor.current_manifest_seq ?? context.task.snapshot.manifest_seq ?? 1,
|
|
562
|
+
task_version: context.task.snapshot.task_version,
|
|
563
|
+
}
|
|
564
|
+
: null,
|
|
565
|
+
};
|
|
566
|
+
await writeSessionManifest(session);
|
|
567
|
+
child.on("error", async () => {
|
|
568
|
+
await rm(scriptDir, { recursive: true, force: true });
|
|
569
|
+
await rm(path.join(contextRoot, "sessions", sanitizeId(session.session_id), "session.json"), { force: true }).catch(() => undefined);
|
|
570
|
+
await removeSessionManifest(session.session_id).catch(() => undefined);
|
|
571
|
+
});
|
|
572
|
+
child.unref();
|
|
573
|
+
return session;
|
|
574
|
+
}
|
|
575
|
+
function buildPowerShellLauncher(input) {
|
|
576
|
+
return [
|
|
577
|
+
"$ErrorActionPreference = 'Stop'",
|
|
578
|
+
`$runnerKind = '${toPsSingleQuoted(input.runner)}'`,
|
|
579
|
+
`$runner = '${toPsSingleQuoted(input.runnerCommand)}'`,
|
|
580
|
+
`$agentId = '${toPsSingleQuoted(input.agentId)}'`,
|
|
581
|
+
`$workdir = '${toPsSingleQuoted(input.workdir)}'`,
|
|
582
|
+
`$contextDir = '${toPsSingleQuoted(input.contextDir)}'`,
|
|
583
|
+
`$onboardingPath = '${toPsSingleQuoted(input.onboardingPath)}'`,
|
|
584
|
+
`$summaryPath = '${toPsSingleQuoted(input.summaryPath)}'`,
|
|
585
|
+
`$inboxDir = '${toPsSingleQuoted(input.inboxDir)}'`,
|
|
586
|
+
`$outboxDir = '${toPsSingleQuoted(input.outboxDir)}'`,
|
|
587
|
+
`$sessionDir = Split-Path $inboxDir -Parent`,
|
|
588
|
+
`$debugLogPath = Join-Path $sessionDir 'worker.log'`,
|
|
589
|
+
`$pidPath = '${toPsSingleQuoted(input.pidPath)}'`,
|
|
590
|
+
`$effort = '${toPsSingleQuoted(input.effort)}'`,
|
|
591
|
+
`$taskId = '${toPsSingleQuoted(input.taskId ?? "")}'`,
|
|
592
|
+
`$env:OFFICE_PROJECT_ID = '${toPsSingleQuoted(input.projectId)}'`,
|
|
593
|
+
`$env:OFFICE_TASK_ID = '${toPsSingleQuoted(input.taskId ?? "")}'`,
|
|
594
|
+
`$env:OFFICE_CONTEXT_DIR = $contextDir`,
|
|
595
|
+
`$env:OFFICE_ONBOARDING_PATH = $onboardingPath`,
|
|
596
|
+
`$env:OFFICE_SUMMARY_PATH = $summaryPath`,
|
|
597
|
+
`$env:OFFICE_LOCAL_SESSION_ID = '${toPsSingleQuoted(input.sessionId)}'`,
|
|
598
|
+
`$env:OFFICE_HOST_ID = '${toPsSingleQuoted(input.hostId)}'`,
|
|
599
|
+
`$env:OFFICE_HOST_DISPLAY_NAME = '${toPsSingleQuoted(input.hostDisplayName)}'`,
|
|
600
|
+
`$env:DOUBLE_PENETRATION_PROJECT_ID = '${toPsSingleQuoted(input.projectId)}'`,
|
|
601
|
+
`$env:DOUBLE_PENETRATION_TASK_ID = '${toPsSingleQuoted(input.taskId ?? "")}'`,
|
|
602
|
+
`$env:DOUBLE_PENETRATION_CONTEXT_PATH = $summaryPath`,
|
|
603
|
+
"$PID | Set-Content -LiteralPath $pidPath",
|
|
604
|
+
"Set-Location -LiteralPath $workdir",
|
|
605
|
+
"New-Item -ItemType Directory -Force -Path $inboxDir | Out-Null",
|
|
606
|
+
"New-Item -ItemType Directory -Force -Path $outboxDir | Out-Null",
|
|
607
|
+
"function Parse-CodexOutput($lines) {",
|
|
608
|
+
" $final = ''",
|
|
609
|
+
" foreach ($line in $lines) {",
|
|
610
|
+
" try {",
|
|
611
|
+
" $payload = $line | ConvertFrom-Json -ErrorAction Stop",
|
|
612
|
+
" if ($payload.type -eq 'item.completed' -and $payload.item.type -eq 'agent_message') {",
|
|
613
|
+
" $text = [string]$payload.item.text",
|
|
614
|
+
" if ($text.Trim()) { $final = $text }",
|
|
615
|
+
" }",
|
|
616
|
+
" } catch {}",
|
|
617
|
+
" }",
|
|
618
|
+
" return $final.Trim()",
|
|
619
|
+
"}",
|
|
620
|
+
"function Parse-ClaudeOutput($lines) {",
|
|
621
|
+
" $final = ''",
|
|
622
|
+
" foreach ($line in $lines) {",
|
|
623
|
+
" try {",
|
|
624
|
+
" $payload = $line | ConvertFrom-Json -ErrorAction Stop",
|
|
625
|
+
" if ($payload.type -eq 'assistant' -and $payload.message.content) {",
|
|
626
|
+
" $textParts = @()",
|
|
627
|
+
" foreach ($entry in $payload.message.content) { if ($entry.type -eq 'text') { $textParts += [string]$entry.text } }",
|
|
628
|
+
" $joined = ($textParts -join '')",
|
|
629
|
+
" if ($joined.Trim()) { $final = $joined }",
|
|
630
|
+
" } elseif ($payload.type -eq 'result' -and $payload.result) {",
|
|
631
|
+
" $text = [string]$payload.result",
|
|
632
|
+
" if ($text.Trim()) { $final = $text }",
|
|
633
|
+
" }",
|
|
634
|
+
" } catch {}",
|
|
635
|
+
" }",
|
|
636
|
+
" return $final.Trim()",
|
|
637
|
+
"}",
|
|
638
|
+
"function Normalize-RunnerFailure([string]$Text, [int]$ExitCode) {",
|
|
639
|
+
" $flat = ($Text -replace '\\s+', ' ').Trim()",
|
|
640
|
+
" if (-not $flat) { return ('Runner exited with code ' + $ExitCode + '.') }",
|
|
641
|
+
" if ($flat -match 'usage limit|out of extra usage|out of usage|try again at') { return 'Runner is unavailable on this machine due to usage limits.' }",
|
|
642
|
+
" if ($flat -match 'unexpected argument') { return 'Runner rejected the launch arguments.' }",
|
|
643
|
+
" if ($flat -match 'not a directory') { return 'Runner rejected the workspace path.' }",
|
|
644
|
+
" if ($flat.Length -gt 220) { return ($flat.Substring(0, 220) + '...') }",
|
|
645
|
+
" return $flat",
|
|
646
|
+
"}",
|
|
647
|
+
"function Convert-ToProcessArgument([string]$Value) {",
|
|
648
|
+
" if ($null -eq $Value) { return '\"\"' }",
|
|
649
|
+
" if ($Value -notmatch '[\\s\"]') { return $Value }",
|
|
650
|
+
" $escaped = $Value -replace '(\\\\*)\"', '$1$1\\\"'",
|
|
651
|
+
" $escaped = $escaped -replace '(\\\\+)$', '$1$1'",
|
|
652
|
+
" return '\"' + $escaped + '\"'",
|
|
653
|
+
"}",
|
|
654
|
+
"function Invoke-AgentPrompt([string]$PromptText) {",
|
|
655
|
+
" $lines = New-Object System.Collections.Generic.List[string]",
|
|
656
|
+
" $start = Get-Date",
|
|
657
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('runner-start ' + $start.ToString('o') + ' exe=' + $runner)",
|
|
658
|
+
" $stdoutPath = Join-Path $sessionDir 'runner.stdout.log'",
|
|
659
|
+
" $stderrPath = Join-Path $sessionDir 'runner.stderr.log'",
|
|
660
|
+
" $inputPath = Join-Path $sessionDir 'runner.prompt.txt'",
|
|
661
|
+
" Set-Content -LiteralPath $inputPath -Value $PromptText -Encoding UTF8",
|
|
662
|
+
" $psi = New-Object System.Diagnostics.ProcessStartInfo",
|
|
663
|
+
" $psi.FileName = $runner",
|
|
664
|
+
" $psi.WorkingDirectory = $workdir",
|
|
665
|
+
" $psi.UseShellExecute = $false",
|
|
666
|
+
" $psi.RedirectStandardOutput = $true",
|
|
667
|
+
" $psi.RedirectStandardError = $true",
|
|
668
|
+
" $psi.CreateNoWindow = $true",
|
|
669
|
+
" $argValues = New-Object System.Collections.Generic.List[string]",
|
|
670
|
+
" if ($runnerKind -eq 'codex') {",
|
|
671
|
+
" foreach ($arg in @('exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', '-C', $workdir)) {",
|
|
672
|
+
" $argValues.Add([string]$arg)",
|
|
673
|
+
" }",
|
|
674
|
+
" if ($effort) {",
|
|
675
|
+
" foreach ($arg in @('-c', ('model_reasoning_effort=\"' + $effort + '\"'))) {",
|
|
676
|
+
" $argValues.Add([string]$arg)",
|
|
677
|
+
" }",
|
|
678
|
+
" }",
|
|
679
|
+
" $argValues.Add('-')",
|
|
680
|
+
" $psi.RedirectStandardInput = $true",
|
|
681
|
+
" } else {",
|
|
682
|
+
" foreach ($arg in @('-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--dangerously-skip-permissions', '--add-dir', $workdir, '--add-dir', $contextDir)) {",
|
|
683
|
+
" $argValues.Add([string]$arg)",
|
|
684
|
+
" }",
|
|
685
|
+
" if ($effort) {",
|
|
686
|
+
" foreach ($arg in @('--effort', $effort)) {",
|
|
687
|
+
" $argValues.Add([string]$arg)",
|
|
688
|
+
" }",
|
|
689
|
+
" }",
|
|
690
|
+
" $argValues.Add($PromptText)",
|
|
691
|
+
" }",
|
|
692
|
+
" $arguments = ($argValues | ForEach-Object { Convert-ToProcessArgument ([string]$_) }) -join ' '",
|
|
693
|
+
" $psi.Arguments = $arguments",
|
|
694
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('runner-args ' + $arguments)",
|
|
695
|
+
" $proc = New-Object System.Diagnostics.Process",
|
|
696
|
+
" $proc.StartInfo = $psi",
|
|
697
|
+
" $null = $proc.Start()",
|
|
698
|
+
" if ($psi.RedirectStandardInput) {",
|
|
699
|
+
" $proc.StandardInput.Write($PromptText)",
|
|
700
|
+
" $proc.StandardInput.Close()",
|
|
701
|
+
" }",
|
|
702
|
+
" $stdoutTask = $proc.StandardOutput.ReadToEndAsync()",
|
|
703
|
+
" $stderrTask = $proc.StandardError.ReadToEndAsync()",
|
|
704
|
+
" $proc.WaitForExit()",
|
|
705
|
+
" $stdout = $stdoutTask.GetAwaiter().GetResult()",
|
|
706
|
+
" $stderr = $stderrTask.GetAwaiter().GetResult()",
|
|
707
|
+
" $exitCode = [int]$proc.ExitCode",
|
|
708
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('runner-exit ' + (Get-Date).ToString('o') + ' code=' + $exitCode)",
|
|
709
|
+
" [System.IO.File]::WriteAllText($stdoutPath, $stdout, [System.Text.UTF8Encoding]::new($false))",
|
|
710
|
+
" [System.IO.File]::WriteAllText($stderrPath, $stderr, [System.Text.UTF8Encoding]::new($false))",
|
|
711
|
+
" if ($stdout.Trim()) {",
|
|
712
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('runner-stdout ' + ($stdout -replace '\\s+', ' '))",
|
|
713
|
+
" foreach ($line in $stdout -split '\\r?\\n') { if ($line.Trim()) { $lines.Add([string]$line); Write-Host $line } }",
|
|
714
|
+
" }",
|
|
715
|
+
" if ($stderr.Trim()) {",
|
|
716
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('runner-stderr ' + ($stderr -replace '\\s+', ' '))",
|
|
717
|
+
" foreach ($line in $stderr -split '\\r?\\n') { if ($line.Trim()) { $lines.Add([string]$line); Write-Host $line } }",
|
|
718
|
+
" }",
|
|
719
|
+
" $parsed = if ($runnerKind -eq 'codex') { Parse-CodexOutput $lines } else { Parse-ClaudeOutput $lines }",
|
|
720
|
+
" if ($parsed.Trim()) {",
|
|
721
|
+
" $normalizedParsed = Normalize-RunnerFailure $parsed.Trim() $exitCode",
|
|
722
|
+
" if ($normalizedParsed -ne $parsed.Trim()) {",
|
|
723
|
+
" throw $normalizedParsed",
|
|
724
|
+
" }",
|
|
725
|
+
" return $parsed.Trim()",
|
|
726
|
+
" }",
|
|
727
|
+
" if ($exitCode -ne 0 -or $stdout.Trim() -or $stderr.Trim()) {",
|
|
728
|
+
" throw (Normalize-RunnerFailure (($stderr + [Environment]::NewLine + $stdout).Trim()) $exitCode)",
|
|
729
|
+
" }",
|
|
730
|
+
" return ''",
|
|
731
|
+
"}",
|
|
732
|
+
"function Write-ReplyFile([hashtable]$Payload) {",
|
|
733
|
+
" $name = ([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds().ToString() + '-' + [guid]::NewGuid().Guid + '.json')",
|
|
734
|
+
" $path = Join-Path $outboxDir $name",
|
|
735
|
+
" [System.IO.File]::WriteAllText($path, ($Payload | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false))",
|
|
736
|
+
" $exists = Test-Path -LiteralPath $path",
|
|
737
|
+
" $size = if ($exists) { (Get-Item -LiteralPath $path).Length } else { 0 }",
|
|
738
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('reply-proof ' + (Get-Date).ToString('o') + ' path=' + $path + ' exists=' + $exists + ' size=' + $size)",
|
|
739
|
+
" return $path",
|
|
740
|
+
"}",
|
|
741
|
+
`$prompt = @'`,
|
|
742
|
+
input.prompt,
|
|
743
|
+
"'@",
|
|
744
|
+
`$Host.UI.RawUI.WindowTitle = 'office :: ${toPsSingleQuoted(input.agentId)}'`,
|
|
745
|
+
"Write-Host '====================================================' -ForegroundColor DarkYellow",
|
|
746
|
+
`Write-Host 'office :: ${toPsSingleQuoted(input.agentId)}' -ForegroundColor Yellow`,
|
|
747
|
+
`Write-Host 'mode=${toPsSingleQuoted(input.mode)} project=${toPsSingleQuoted(input.projectId)} task=${toPsSingleQuoted(input.taskId ?? "none")}' -ForegroundColor DarkYellow`,
|
|
748
|
+
"Write-Host ('onboarding: ' + $onboardingPath) -ForegroundColor DarkGray",
|
|
749
|
+
"Write-Host ('summary: ' + $summaryPath) -ForegroundColor DarkGray",
|
|
750
|
+
"Write-Host 'This shell is the live room worker. New prompts arrive here from the browser.' -ForegroundColor DarkGray",
|
|
751
|
+
"Write-Host '====================================================' -ForegroundColor DarkYellow",
|
|
752
|
+
"Write-Host $prompt -ForegroundColor Gray",
|
|
753
|
+
"Add-Content -LiteralPath $debugLogPath -Value ('boot ' + (Get-Date).ToString('o') + ' prompt=' + ($prompt -replace '\\s+', ' '))",
|
|
754
|
+
"while ($true) {",
|
|
755
|
+
" $next = Get-ChildItem -LiteralPath $inboxDir -Filter '*.json' -ErrorAction SilentlyContinue | Sort-Object Name | Select-Object -First 1",
|
|
756
|
+
" if (-not $next) { Start-Sleep -Milliseconds 450; continue }",
|
|
757
|
+
" try {",
|
|
758
|
+
" $job = Get-Content -Raw -LiteralPath $next.FullName | ConvertFrom-Json -ErrorAction Stop",
|
|
759
|
+
" if ($job.type -eq 'stop') { Remove-Item -LiteralPath $next.FullName -Force -ErrorAction SilentlyContinue; break }",
|
|
760
|
+
" Write-Host ''",
|
|
761
|
+
" Write-Host ('[' + (Get-Date).ToString('HH:mm:ss') + '] ' + $job.author_label + ' -> ' + $agentId) -ForegroundColor Cyan",
|
|
762
|
+
" Write-Host $job.text -ForegroundColor White",
|
|
763
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('message ' + (Get-Date).ToString('o') + ' seq=' + $job.seq + ' text=' + ($job.text -replace '\\s+', ' '))",
|
|
764
|
+
" $deliveryPrompt = @\"",
|
|
765
|
+
"Read onboarding.txt at:",
|
|
766
|
+
"$onboardingPath",
|
|
767
|
+
"",
|
|
768
|
+
"Read summary.txt at:",
|
|
769
|
+
"$summaryPath",
|
|
770
|
+
"",
|
|
771
|
+
"New room message from $($job.author_label) (seq $($job.seq)):",
|
|
772
|
+
"$($job.text)",
|
|
773
|
+
"",
|
|
774
|
+
"Respond to the shared room in concise plain text.",
|
|
775
|
+
"\"@",
|
|
776
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('invoke ' + (Get-Date).ToString('o') + ' runner=' + $runnerKind)",
|
|
777
|
+
" $replyText = Invoke-AgentPrompt $deliveryPrompt",
|
|
778
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('reply-text ' + (Get-Date).ToString('o') + ' text=' + ($replyText -replace '\\s+', ' '))",
|
|
779
|
+
" if ($replyText.Trim()) {",
|
|
780
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('write-reply ' + (Get-Date).ToString('o'))",
|
|
781
|
+
" $replyPath = Write-ReplyFile @{",
|
|
782
|
+
" message_id = ('msg_' + [guid]::NewGuid().Guid)",
|
|
783
|
+
" author_type = 'agent'",
|
|
784
|
+
" author_id = $agentId",
|
|
785
|
+
" author_label = $agentId",
|
|
786
|
+
" text = $replyText.Trim()",
|
|
787
|
+
" task_id = if ($job.task_id) { [string]$job.task_id } elseif ($taskId) { $taskId } else { $null }",
|
|
788
|
+
" reply_to_seq = [int]$job.seq",
|
|
789
|
+
" }",
|
|
790
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('reply-proof-final ' + (Get-Date).ToString('o') + ' path=' + $replyPath + ' exists=' + (Test-Path -LiteralPath $replyPath) + ' size=' + ((Get-Item -LiteralPath $replyPath).Length))",
|
|
791
|
+
" }",
|
|
792
|
+
" } catch {",
|
|
793
|
+
" Add-Content -LiteralPath $debugLogPath -Value ('catch ' + (Get-Date).ToString('o') + ' error=' + $_.Exception.Message)",
|
|
794
|
+
" Write-ReplyFile @{",
|
|
795
|
+
" message_id = ('msg_' + [guid]::NewGuid().Guid)",
|
|
796
|
+
" author_type = 'system'",
|
|
797
|
+
" author_id = $env:OFFICE_HOST_ID",
|
|
798
|
+
" author_label = $env:OFFICE_HOST_DISPLAY_NAME",
|
|
799
|
+
" text = ($agentId + ' failed to answer: ' + $_.Exception.Message)",
|
|
800
|
+
" task_id = if ($job.task_id) { [string]$job.task_id } elseif ($taskId) { $taskId } else { $null }",
|
|
801
|
+
" reply_to_seq = if ($job.seq) { [int]$job.seq } else { $null }",
|
|
802
|
+
" }",
|
|
803
|
+
" } finally {",
|
|
804
|
+
" Remove-Item -LiteralPath $next.FullName -Force -ErrorAction SilentlyContinue",
|
|
805
|
+
" }",
|
|
806
|
+
"}",
|
|
807
|
+
"Write-Host ''",
|
|
808
|
+
"Write-Host 'Agent loop ended.' -ForegroundColor DarkGray",
|
|
809
|
+
].join("\r\n");
|
|
810
|
+
}
|
|
811
|
+
function buildInteractivePrompt(runtime, command, environment, task) {
|
|
812
|
+
const runner = command.runner ?? "codex";
|
|
813
|
+
const mode = command.mode ?? "attach";
|
|
814
|
+
const snapshot = task?.snapshot ?? null;
|
|
815
|
+
const modeInstruction = mode === "brainstorm"
|
|
816
|
+
? "Brainstorm and align before editing files."
|
|
817
|
+
: mode === "review"
|
|
818
|
+
? "Inspect and review the task; do not make changes unless clearly required."
|
|
819
|
+
: mode === "attach"
|
|
820
|
+
? "Catch up on the workspace state and be ready for follow-up."
|
|
821
|
+
: "Execute the current task in the workspace.";
|
|
822
|
+
const promptParts = [
|
|
823
|
+
`You are ${command.agent_id}, a ${runner} agent working in the shared office.`,
|
|
824
|
+
`Project: ${runtime.projectId}.`,
|
|
825
|
+
snapshot?.title ? `Task: ${snapshot.title}.` : "There may not be an active task yet.",
|
|
826
|
+
snapshot?.accepted_understanding ? `Shared understanding: ${snapshot.accepted_understanding}` : null,
|
|
827
|
+
environment.repo_name
|
|
828
|
+
? `Workspace repo: ${environment.repo_name}${environment.git_branch ? ` on ${environment.git_branch}` : ""}.`
|
|
829
|
+
: `Workspace directory: ${path.resolve(command.workdir || resolveProjectWorkdir(runtime.defaultWorkdir, runtime.projectId))}.`,
|
|
830
|
+
"Read onboarding.txt at OFFICE_ONBOARDING_PATH, then read summary.txt at OFFICE_SUMMARY_PATH.",
|
|
831
|
+
"Stay inside this current project room. Do not assume sleeping context from older conversations.",
|
|
832
|
+
modeInstruction,
|
|
833
|
+
].filter(Boolean);
|
|
834
|
+
return promptParts.join(" ");
|
|
835
|
+
}
|
|
836
|
+
function buildOnboardingText() {
|
|
837
|
+
return [
|
|
838
|
+
"office host onboarding",
|
|
839
|
+
"You are joining one active current-project room.",
|
|
840
|
+
"Work only inside the current project directory.",
|
|
841
|
+
"Treat summary.txt as the compacted room state for this conversation.",
|
|
842
|
+
"Do not assume sleeping agents or hidden history from older chats.",
|
|
843
|
+
"Use the visible shell for this room and keep responses concise, concrete, and collaborative.",
|
|
844
|
+
"If you make progress, blockers, or decisions, reflect them clearly in the room.",
|
|
845
|
+
].join("\n");
|
|
846
|
+
}
|
|
847
|
+
function buildSummaryText(runtime, command, environment, project, task, bootstrap) {
|
|
848
|
+
const taskSnapshot = task?.snapshot ?? null;
|
|
849
|
+
const office = project?.office ?? null;
|
|
850
|
+
const roomMessages = Array.isArray(office?.room?.recent_messages) ? office.room.recent_messages.slice(-10) : [];
|
|
851
|
+
const recentDialogue = roomMessages.length
|
|
852
|
+
? roomMessages.map((entry) => `- ${entry.author_label ?? entry.author_id}: ${String(entry.text ?? "").slice(0, 360)}`).join("\n")
|
|
853
|
+
: "No room messages yet.";
|
|
854
|
+
const activeAgents = Array.isArray(office?.hosts)
|
|
855
|
+
? office.hosts.flatMap((host) => host.running_sessions ?? []).map((session) => session.agent_id).filter((name) => Boolean(name))
|
|
856
|
+
: [];
|
|
857
|
+
const focusTitle = office?.current_project?.focus_task_title ?? taskSnapshot?.title ?? "No focused task";
|
|
858
|
+
const focusId = office?.current_project?.focus_task_id ?? taskSnapshot?.task_id ?? "none";
|
|
859
|
+
const directive = office?.attention?.human_directives?.[0]?.text ?? "No explicit conductor directive captured.";
|
|
860
|
+
const lastDeliverable = office?.artifacts?.[0]
|
|
861
|
+
? `${office.artifacts[0].task_title}: ${office.artifacts[0].summary ?? "completed"}`
|
|
862
|
+
: "No deliverables yet.";
|
|
863
|
+
return [
|
|
864
|
+
"current project summary",
|
|
865
|
+
`generated_at: ${new Date().toISOString()}`,
|
|
866
|
+
"",
|
|
867
|
+
"[project]",
|
|
868
|
+
`project_id: ${runtime.projectId}`,
|
|
869
|
+
`project_name: ${project?.name ?? runtime.projectId}`,
|
|
870
|
+
`workspace_dir: ${path.resolve(command.workdir || resolveProjectWorkdir(runtime.defaultWorkdir, runtime.projectId))}`,
|
|
871
|
+
`headline: ${office?.current_project?.headline ?? project?.accepted_understanding ?? "No headline yet."}`,
|
|
872
|
+
"",
|
|
873
|
+
"[room]",
|
|
874
|
+
`conductor: ${office?.room?.settings?.conductor_name ?? "You"}`,
|
|
875
|
+
`all_agents_listening: ${Boolean(office?.room?.settings?.all_agents_listening ?? true)}`,
|
|
876
|
+
`focus_task_id: ${focusId}`,
|
|
877
|
+
`focus_task_title: ${focusTitle}`,
|
|
878
|
+
`latest_directive: ${directive}`,
|
|
879
|
+
"",
|
|
880
|
+
"[active_agents]",
|
|
881
|
+
activeAgents.length ? activeAgents.map((name) => `- ${name}`).join("\n") : "No active agents in this room.",
|
|
882
|
+
"",
|
|
883
|
+
"[workspace]",
|
|
884
|
+
`repo_root: ${environment.repo_root ?? "none"}`,
|
|
885
|
+
`repo_name: ${environment.repo_name ?? "none"}`,
|
|
886
|
+
`git_branch: ${environment.git_branch ?? "none"}`,
|
|
887
|
+
`git_head: ${environment.git_head ?? "none"}`,
|
|
888
|
+
`git_dirty: ${environment.git_dirty}`,
|
|
889
|
+
`repo_status: staged=${environment.staged_files} changed=${environment.changed_files} untracked=${environment.untracked_files}`,
|
|
890
|
+
"",
|
|
891
|
+
"[latest_deliverable]",
|
|
892
|
+
lastDeliverable,
|
|
893
|
+
"",
|
|
894
|
+
"[agent_session_bootstrap]",
|
|
895
|
+
`session_id: ${bootstrap?.fence.session_id ?? "none"}`,
|
|
896
|
+
`session_epoch: ${bootstrap?.fence.session_epoch ?? "none"}`,
|
|
897
|
+
`manifest_seq: ${bootstrap?.descriptor.current_manifest_seq ?? "none"}`,
|
|
898
|
+
"",
|
|
899
|
+
"[recent_room_messages]",
|
|
900
|
+
recentDialogue,
|
|
901
|
+
].join("\n");
|
|
902
|
+
}
|
|
903
|
+
async function refreshSessionSummary(runtime, session, project) {
|
|
904
|
+
if (!session.summary_path) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const environment = collectEnvironment(session.workdir);
|
|
908
|
+
const summaryText = buildSummaryText(runtime, {
|
|
909
|
+
command_id: "",
|
|
910
|
+
command_type: "spawn",
|
|
911
|
+
runner: session.runner,
|
|
912
|
+
agent_id: session.agent_id,
|
|
913
|
+
mode: session.mode,
|
|
914
|
+
effort: session.effort ?? "high",
|
|
915
|
+
workdir: session.workdir,
|
|
916
|
+
task_id: session.task_id ?? null,
|
|
917
|
+
prompt: null,
|
|
918
|
+
}, environment, project, null, null);
|
|
919
|
+
await writeFile(session.summary_path, summaryText, "utf8");
|
|
920
|
+
}
|
|
921
|
+
async function waitForPid(scriptPath, pidPath, timeoutMs = 6000) {
|
|
922
|
+
const startedAt = Date.now();
|
|
923
|
+
let candidatePid = null;
|
|
924
|
+
let candidateSince = 0;
|
|
925
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
926
|
+
let nextCandidate = null;
|
|
927
|
+
try {
|
|
928
|
+
const text = (await readFile(pidPath, "utf8")).trim();
|
|
929
|
+
const pid = Number(text);
|
|
930
|
+
if (Number.isFinite(pid) && pid > 0 && isProcessAlive(pid) && processCommandLineHasScript(pid, scriptPath)) {
|
|
931
|
+
nextCandidate = pid;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
// Wait for the tab shell to boot and write the pid file.
|
|
936
|
+
}
|
|
937
|
+
const fallback = findShellPidByScript(scriptPath);
|
|
938
|
+
if (fallback) {
|
|
939
|
+
nextCandidate = Math.max(nextCandidate ?? 0, fallback);
|
|
940
|
+
}
|
|
941
|
+
if (nextCandidate && nextCandidate === candidatePid && Date.now() - candidateSince >= 1200) {
|
|
942
|
+
return nextCandidate;
|
|
943
|
+
}
|
|
944
|
+
if (nextCandidate && nextCandidate !== candidatePid) {
|
|
945
|
+
candidatePid = nextCandidate;
|
|
946
|
+
candidateSince = Date.now();
|
|
947
|
+
}
|
|
948
|
+
await sleep(150);
|
|
949
|
+
}
|
|
950
|
+
return candidatePid && isProcessAlive(candidatePid) ? candidatePid : null;
|
|
951
|
+
}
|
|
952
|
+
function processCommandLineHasScript(pid, scriptPath) {
|
|
953
|
+
const escaped = scriptPath.replace(/\\/g, "\\\\").replace(/'/g, "''");
|
|
954
|
+
const result = spawnSync("powershell.exe", [
|
|
955
|
+
"-NoProfile",
|
|
956
|
+
"-ExecutionPolicy",
|
|
957
|
+
"Bypass",
|
|
958
|
+
"-Command",
|
|
959
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Where-Object { $_.CommandLine -like '*${escaped}*' } | Select-Object -First 1 -ExpandProperty ProcessId)`,
|
|
960
|
+
], {
|
|
961
|
+
encoding: "utf8",
|
|
962
|
+
windowsHide: true,
|
|
963
|
+
});
|
|
964
|
+
return result.status === 0 && String(result.stdout ?? "").trim() !== "";
|
|
965
|
+
}
|
|
966
|
+
function findShellPidByScript(scriptPath) {
|
|
967
|
+
const escaped = scriptPath.replace(/\\/g, "\\\\").replace(/'/g, "''");
|
|
968
|
+
const result = spawnSync("powershell.exe", [
|
|
969
|
+
"-NoProfile",
|
|
970
|
+
"-ExecutionPolicy",
|
|
971
|
+
"Bypass",
|
|
972
|
+
"-Command",
|
|
973
|
+
`(Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine -like '*${escaped}*' } | Select-Object -First 1 -ExpandProperty ProcessId)`,
|
|
974
|
+
], {
|
|
975
|
+
encoding: "utf8",
|
|
976
|
+
windowsHide: true,
|
|
977
|
+
});
|
|
978
|
+
if (result.status !== 0) {
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
const pids = String(result.stdout ?? "")
|
|
982
|
+
.split(/\r?\n/)
|
|
983
|
+
.map((line) => Number(line.trim()))
|
|
984
|
+
.filter((pid) => Number.isFinite(pid) && pid > 0);
|
|
985
|
+
if (pids.length === 0) {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
return Math.max(...pids);
|
|
989
|
+
}
|
|
990
|
+
function findShellPidBySessionId(sessionId) {
|
|
991
|
+
const escaped = sessionId.replace(/'/g, "''");
|
|
992
|
+
const result = spawnSync("powershell.exe", [
|
|
993
|
+
"-NoProfile",
|
|
994
|
+
"-ExecutionPolicy",
|
|
995
|
+
"Bypass",
|
|
996
|
+
"-Command",
|
|
997
|
+
`(Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine -like '*${escaped}*' } | Select-Object -First 1 -ExpandProperty ProcessId)`,
|
|
998
|
+
], {
|
|
999
|
+
encoding: "utf8",
|
|
1000
|
+
windowsHide: true,
|
|
1001
|
+
});
|
|
1002
|
+
if (result.status !== 0) {
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
const pid = Number(String(result.stdout ?? "").trim());
|
|
1006
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
1007
|
+
}
|
|
1008
|
+
async function refreshSessions(runtime) {
|
|
1009
|
+
let changed = false;
|
|
1010
|
+
for (const [sessionId, session] of sessions.entries()) {
|
|
1011
|
+
if (session.status !== "running") {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const scriptPath = session.script_dir ? path.join(session.script_dir, `${session.session_id}.ps1`) : null;
|
|
1015
|
+
if ((!session.shell_pid || !isProcessAlive(session.shell_pid)) && scriptPath) {
|
|
1016
|
+
const resolved = findShellPidByScript(scriptPath);
|
|
1017
|
+
if (resolved) {
|
|
1018
|
+
session.shell_pid = resolved;
|
|
1019
|
+
session.missed_checks = 0;
|
|
1020
|
+
changed = true;
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (!session.shell_pid) {
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
if (!isProcessAlive(session.shell_pid)) {
|
|
1028
|
+
session.missed_checks = (session.missed_checks ?? 0) + 1;
|
|
1029
|
+
const ageMs = Date.now() - new Date(session.launched_at).getTime();
|
|
1030
|
+
if (ageMs < 15000 || session.missed_checks < 3) {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
session.status = "completed";
|
|
1034
|
+
if (session.script_dir) {
|
|
1035
|
+
void rm(session.script_dir, { recursive: true, force: true });
|
|
1036
|
+
}
|
|
1037
|
+
sessions.delete(sessionId);
|
|
1038
|
+
void sendStoppedAgentStatus(runtime, session, "Session ended.").catch(() => undefined);
|
|
1039
|
+
changed = true;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
session.missed_checks = 0;
|
|
1043
|
+
}
|
|
1044
|
+
if (changed) {
|
|
1045
|
+
await persistSessions();
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
function isProcessAlive(pid) {
|
|
1049
|
+
const result = spawnSync("tasklist.exe", ["/FI", `PID eq ${pid}`], {
|
|
1050
|
+
encoding: "utf8",
|
|
1051
|
+
windowsHide: true,
|
|
1052
|
+
});
|
|
1053
|
+
if (result.status !== 0) {
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
return String(result.stdout ?? "").includes(String(pid));
|
|
1057
|
+
}
|
|
1058
|
+
export async function stopSession(runtime, sessionId) {
|
|
1059
|
+
const session = sessions.get(sessionId) ?? null;
|
|
1060
|
+
if (!session) {
|
|
1061
|
+
const shellPid = findShellPidBySessionId(sessionId);
|
|
1062
|
+
if (shellPid) {
|
|
1063
|
+
spawnSync("taskkill.exe", ["/PID", String(shellPid), "/T", "/F"], {
|
|
1064
|
+
encoding: "utf8",
|
|
1065
|
+
windowsHide: true,
|
|
1066
|
+
});
|
|
1067
|
+
return {
|
|
1068
|
+
session_id: sessionId,
|
|
1069
|
+
project_id: runtime.projectId,
|
|
1070
|
+
runner: "codex",
|
|
1071
|
+
agent_id: sessionId,
|
|
1072
|
+
mode: "attach",
|
|
1073
|
+
workdir: resolveProjectWorkdir(runtime.defaultWorkdir, runtime.projectId),
|
|
1074
|
+
status: "completed",
|
|
1075
|
+
launched_at: new Date().toISOString(),
|
|
1076
|
+
task_id: null,
|
|
1077
|
+
shell_pid: shellPid,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
if (session.shell_pid) {
|
|
1083
|
+
spawnSync("taskkill.exe", ["/PID", String(session.shell_pid), "/T", "/F"], {
|
|
1084
|
+
encoding: "utf8",
|
|
1085
|
+
windowsHide: true,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
if (session.script_dir) {
|
|
1089
|
+
await rm(session.script_dir, { recursive: true, force: true }).catch(() => undefined);
|
|
1090
|
+
}
|
|
1091
|
+
session.status = "completed";
|
|
1092
|
+
sessions.delete(sessionId);
|
|
1093
|
+
await persistSessions();
|
|
1094
|
+
await sendStoppedAgentStatus(runtime, session, "Removed from the room.");
|
|
1095
|
+
return session;
|
|
1096
|
+
}
|
|
1097
|
+
export async function persistSessions() {
|
|
1098
|
+
await Promise.all([
|
|
1099
|
+
savePersistedHostSessions(Array.from(sessions.values())),
|
|
1100
|
+
...Array.from(sessions.values()).map((session) => writeSessionManifest(session)),
|
|
1101
|
+
]);
|
|
1102
|
+
}
|
|
1103
|
+
async function writeSessionManifest(session) {
|
|
1104
|
+
if (!session.context_dir) {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const sessionDir = path.join(session.context_dir, "sessions", sanitizeId(session.session_id));
|
|
1108
|
+
await mkdir(sessionDir, { recursive: true });
|
|
1109
|
+
await writeFile(path.join(sessionDir, "session.json"), JSON.stringify(session, null, 2), "utf8");
|
|
1110
|
+
}
|
|
1111
|
+
async function removeSessionManifest(sessionId) {
|
|
1112
|
+
for (const baseDir of candidateSessionBases(process.cwd())) {
|
|
1113
|
+
const sessionJson = path.join(baseDir, sanitizeId(sessionId), "session.json");
|
|
1114
|
+
await rm(sessionJson, { force: true }).catch(() => undefined);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
function candidateSessionBases(defaultWorkdir, projectId) {
|
|
1118
|
+
const bases = new Set();
|
|
1119
|
+
bases.add(path.join(path.resolve(defaultWorkdir), ".office-host", "sessions"));
|
|
1120
|
+
if (projectId) {
|
|
1121
|
+
bases.add(path.join(resolveProjectWorkdir(defaultWorkdir, projectId), ".office-host", "sessions"));
|
|
1122
|
+
}
|
|
1123
|
+
bases.add(path.join(process.cwd(), ".office-host", "sessions"));
|
|
1124
|
+
return Array.from(bases);
|
|
1125
|
+
}
|
|
1126
|
+
async function restoreSessions(runtime) {
|
|
1127
|
+
const persisted = await loadPersistedHostSessions();
|
|
1128
|
+
for (const session of persisted) {
|
|
1129
|
+
if (session.status !== "running") {
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
if (session.project_id && session.project_id !== runtime.projectId) {
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
if (session.shell_pid && !isProcessAlive(session.shell_pid)) {
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
sessions.set(session.session_id, session);
|
|
1139
|
+
}
|
|
1140
|
+
const bases = candidateSessionBases(runtime.defaultWorkdir, runtime.projectId);
|
|
1141
|
+
for (const baseDir of bases) {
|
|
1142
|
+
if (!existsSync(baseDir)) {
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
let entries = [];
|
|
1146
|
+
try {
|
|
1147
|
+
entries = await readdir(baseDir, { withFileTypes: true }).then((items) => items.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
for (const entry of entries) {
|
|
1153
|
+
const manifestPath = path.join(baseDir, entry, "session.json");
|
|
1154
|
+
if (!existsSync(manifestPath)) {
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
try {
|
|
1158
|
+
const session = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
1159
|
+
if (!session?.session_id || session.status !== "running") {
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
if (session.project_id && session.project_id !== runtime.projectId) {
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
if (session.shell_pid && !isProcessAlive(session.shell_pid)) {
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
sessions.set(session.session_id, session);
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (sessions.size > 0) {
|
|
1176
|
+
await persistSessions().catch(() => undefined);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
async function sendStoppedAgentStatus(runtime, session, summary) {
|
|
1180
|
+
if (!session.task_id || !session.bootstrap) {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
const envelope = {
|
|
1184
|
+
protocol_version: "1.0",
|
|
1185
|
+
command_schema_version: 1,
|
|
1186
|
+
command_id: `${session.agent_id}:status.upsert:${crypto.randomUUID()}`,
|
|
1187
|
+
session_id: session.bootstrap.session_id,
|
|
1188
|
+
session_epoch: session.bootstrap.session_epoch,
|
|
1189
|
+
agent_id: session.agent_id,
|
|
1190
|
+
task_id: session.task_id,
|
|
1191
|
+
observed_task_version: session.bootstrap.task_version,
|
|
1192
|
+
bundle_seq: session.bootstrap.bundle_seq,
|
|
1193
|
+
type: "status.upsert",
|
|
1194
|
+
payload: {
|
|
1195
|
+
task_summary: summary,
|
|
1196
|
+
state: "done",
|
|
1197
|
+
},
|
|
1198
|
+
sent_at: new Date().toISOString(),
|
|
1199
|
+
};
|
|
1200
|
+
await postJson(runtime, "/api/agents/events", { commands: [envelope] }, session.bootstrap.session_token);
|
|
1201
|
+
}
|
|
1202
|
+
function collectEnvironment(workdir) {
|
|
1203
|
+
const resolved = path.resolve(workdir);
|
|
1204
|
+
if (!existsSync(resolved)) {
|
|
1205
|
+
return {
|
|
1206
|
+
workdir_exists: false,
|
|
1207
|
+
repo_root: null,
|
|
1208
|
+
repo_name: null,
|
|
1209
|
+
git_branch: null,
|
|
1210
|
+
git_head: null,
|
|
1211
|
+
git_dirty: false,
|
|
1212
|
+
staged_files: 0,
|
|
1213
|
+
changed_files: 0,
|
|
1214
|
+
untracked_files: 0,
|
|
1215
|
+
node_version: process.version.replace(/^v/, ""),
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
const repo_root = runCommand("git", ["rev-parse", "--show-toplevel"], resolved);
|
|
1219
|
+
const git_branch = repo_root ? runCommand("git", ["branch", "--show-current"], resolved) : null;
|
|
1220
|
+
const git_head = repo_root ? runCommand("git", ["rev-parse", "--short", "HEAD"], resolved) : null;
|
|
1221
|
+
const statusOutput = repo_root
|
|
1222
|
+
? runCommand("git", ["status", "--porcelain", "--untracked-files=all", "--", "."], resolved)
|
|
1223
|
+
: null;
|
|
1224
|
+
let staged_files = 0;
|
|
1225
|
+
let changed_files = 0;
|
|
1226
|
+
let untracked_files = 0;
|
|
1227
|
+
for (const line of (statusOutput ?? "").split(/\r?\n/).filter(Boolean)) {
|
|
1228
|
+
if (line.startsWith("??")) {
|
|
1229
|
+
untracked_files += 1;
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
const indexStatus = line[0] ?? " ";
|
|
1233
|
+
const worktreeStatus = line[1] ?? " ";
|
|
1234
|
+
if (indexStatus !== " ") {
|
|
1235
|
+
staged_files += 1;
|
|
1236
|
+
}
|
|
1237
|
+
if (worktreeStatus !== " ") {
|
|
1238
|
+
changed_files += 1;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return {
|
|
1242
|
+
workdir_exists: true,
|
|
1243
|
+
repo_root,
|
|
1244
|
+
repo_name: path.basename(resolved),
|
|
1245
|
+
git_branch,
|
|
1246
|
+
git_head,
|
|
1247
|
+
git_dirty: staged_files + changed_files + untracked_files > 0,
|
|
1248
|
+
staged_files,
|
|
1249
|
+
changed_files,
|
|
1250
|
+
untracked_files,
|
|
1251
|
+
node_version: process.version.replace(/^v/, ""),
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
function runCommand(command, args, cwd) {
|
|
1255
|
+
const result = spawnSync(command, args, {
|
|
1256
|
+
cwd,
|
|
1257
|
+
encoding: "utf8",
|
|
1258
|
+
windowsHide: true,
|
|
1259
|
+
});
|
|
1260
|
+
if (result.status !== 0) {
|
|
1261
|
+
return null;
|
|
1262
|
+
}
|
|
1263
|
+
const text = String(result.stdout ?? "").trim();
|
|
1264
|
+
return text || null;
|
|
1265
|
+
}
|
|
1266
|
+
export async function getJson(runtime, pathname, tokenOverride) {
|
|
1267
|
+
return requestJson(runtime, pathname, {
|
|
1268
|
+
method: "GET",
|
|
1269
|
+
tokenOverride,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
export async function postJson(runtime, pathname, body, tokenOverride) {
|
|
1273
|
+
return requestJson(runtime, pathname, {
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
body,
|
|
1276
|
+
tokenOverride,
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
export async function postRoomMessageUpsert(runtime, body) {
|
|
1280
|
+
return postJson(runtime, `/api/projects/${runtime.projectId}/room/messages/upsert`, body);
|
|
1281
|
+
}
|
|
1282
|
+
function parseArgs(argv) {
|
|
1283
|
+
const result = {};
|
|
1284
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1285
|
+
const item = argv[i];
|
|
1286
|
+
if (!item.startsWith("--")) {
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
const key = item.slice(2);
|
|
1290
|
+
const value = argv[i + 1];
|
|
1291
|
+
if (!value || value.startsWith("--")) {
|
|
1292
|
+
result[key] = "true";
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
result[key] = value;
|
|
1296
|
+
i += 1;
|
|
1297
|
+
}
|
|
1298
|
+
return result;
|
|
1299
|
+
}
|
|
1300
|
+
export function sanitizeId(value) {
|
|
1301
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
1302
|
+
}
|
|
1303
|
+
function normalizePollMs(value) {
|
|
1304
|
+
const parsed = Number(value);
|
|
1305
|
+
if (!Number.isFinite(parsed)) {
|
|
1306
|
+
return 900;
|
|
1307
|
+
}
|
|
1308
|
+
return Math.max(600, Math.min(parsed, 1500));
|
|
1309
|
+
}
|
|
1310
|
+
function toPsSingleQuoted(value) {
|
|
1311
|
+
return value.replace(/'/g, "''");
|
|
1312
|
+
}
|
|
1313
|
+
function safeScriptTempBase() {
|
|
1314
|
+
// Prefer a spaceless system temp so wt.exe can pass the path to PowerShell without quoting issues.
|
|
1315
|
+
const candidates = ["C:\\Temp", "C:\\Windows\\Temp", "C:\\ProgramData\\Temp"];
|
|
1316
|
+
for (const candidate of candidates) {
|
|
1317
|
+
if (existsSync(candidate)) {
|
|
1318
|
+
return candidate;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return os.tmpdir();
|
|
1322
|
+
}
|
|
1323
|
+
export function resolveProjectWorkdir(baseWorkdir, projectId) {
|
|
1324
|
+
return path.join(path.resolve(baseWorkdir), "projects", sanitizeId(projectId));
|
|
1325
|
+
}
|
|
1326
|
+
async function persistRoomCursor(runtime) {
|
|
1327
|
+
await upsertHostConfig({
|
|
1328
|
+
host_id: runtime.hostId,
|
|
1329
|
+
display_name: runtime.displayName,
|
|
1330
|
+
base_url: runtime.baseUrl,
|
|
1331
|
+
project_id: runtime.projectId,
|
|
1332
|
+
workdir: runtime.defaultWorkdir,
|
|
1333
|
+
token: runtime.token,
|
|
1334
|
+
room_cursor_seq: runtime.roomCursorSeq,
|
|
1335
|
+
poll_ms: runtime.pollMs,
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
async function refreshHostToken(runtime) {
|
|
1339
|
+
runtime.token = await registerHostToken({
|
|
1340
|
+
baseUrl: runtime.baseUrl,
|
|
1341
|
+
projectId: runtime.projectId,
|
|
1342
|
+
hostId: runtime.hostId,
|
|
1343
|
+
displayName: runtime.displayName,
|
|
1344
|
+
workdir: runtime.defaultWorkdir,
|
|
1345
|
+
pollMs: runtime.pollMs,
|
|
1346
|
+
});
|
|
1347
|
+
await upsertHostConfig({
|
|
1348
|
+
host_id: runtime.hostId,
|
|
1349
|
+
display_name: runtime.displayName,
|
|
1350
|
+
base_url: runtime.baseUrl,
|
|
1351
|
+
project_id: runtime.projectId,
|
|
1352
|
+
workdir: runtime.defaultWorkdir,
|
|
1353
|
+
token: runtime.token,
|
|
1354
|
+
room_cursor_seq: runtime.roomCursorSeq,
|
|
1355
|
+
poll_ms: runtime.pollMs,
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
async function requestJson(runtime, pathname, options) {
|
|
1359
|
+
const attempts = 3;
|
|
1360
|
+
let lastError = null;
|
|
1361
|
+
let refreshedAfter401 = false;
|
|
1362
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1363
|
+
try {
|
|
1364
|
+
const response = await fetch(`${runtime.baseUrl}${pathname}`, {
|
|
1365
|
+
method: options.method,
|
|
1366
|
+
headers: {
|
|
1367
|
+
...(options.method === "POST" ? { "content-type": "application/json" } : {}),
|
|
1368
|
+
...(options.tokenOverride ?? runtime.token ? { authorization: `Bearer ${options.tokenOverride ?? runtime.token}` } : {}),
|
|
1369
|
+
},
|
|
1370
|
+
body: options.method === "POST" ? JSON.stringify(options.body ?? null) : undefined,
|
|
1371
|
+
signal: AbortSignal.timeout(12000),
|
|
1372
|
+
});
|
|
1373
|
+
if (!response.ok) {
|
|
1374
|
+
if (response.status === 401 && !options.tokenOverride && !refreshedAfter401) {
|
|
1375
|
+
refreshedAfter401 = true;
|
|
1376
|
+
await refreshHostToken(runtime);
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
const text = await response.text();
|
|
1380
|
+
if (attempt < attempts && shouldRetryStatus(response.status)) {
|
|
1381
|
+
await sleep(350 * attempt);
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
throw new Error(`${pathname} failed: ${response.status} ${text}`);
|
|
1385
|
+
}
|
|
1386
|
+
return response.json();
|
|
1387
|
+
}
|
|
1388
|
+
catch (error) {
|
|
1389
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
1390
|
+
if (attempt >= attempts) {
|
|
1391
|
+
break;
|
|
1392
|
+
}
|
|
1393
|
+
await sleep(350 * attempt);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
throw lastError ?? new Error(`${pathname} failed`);
|
|
1397
|
+
}
|
|
1398
|
+
function shouldRetryStatus(status) {
|
|
1399
|
+
return status === 408 || status === 425 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
1400
|
+
}
|
|
1401
|
+
function sleep(ms) {
|
|
1402
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1403
|
+
}
|