wtagent 0.1.0-alpha.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.
@@ -0,0 +1,629 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Command } from "commander";
5
+ import { confirm, input } from "@inquirer/prompts";
6
+ import { ChatGPTWebAdapter } from "../browser/chatgpt-web-adapter.js";
7
+ import { launchNativeLoginBrowser } from "../browser/native-login.js";
8
+ import {
9
+ ensureDirectory,
10
+ getAppDataDir,
11
+ getChromeProfileDir,
12
+ getSessionsDir,
13
+ getTasksDir,
14
+ } from "../platform/paths.js";
15
+ import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
16
+ import { AgentRuntime } from "../runtime/agent-runtime.js";
17
+ import { AgentSession } from "../session/agent-session.js";
18
+ import { PolicyEngine } from "../policy/policy-engine.js";
19
+ import { createDefaultToolRegistry } from "../tools/default-tools.js";
20
+ import { ProcessManager } from "../tools/process-manager.js";
21
+ import { resolveLimits } from "../shared/limits.js";
22
+ import { EXPORTERS } from "../session/session-export.js";
23
+ import { extractAtMentions } from "./at-files.js";
24
+ import { createRenderer } from "./render-events.js";
25
+
26
+ function resolveRuntimePaths(options) {
27
+ const appDataDir = path.resolve(options.home ?? getAppDataDir());
28
+ return {
29
+ appDataDir,
30
+ profileDir: path.resolve(
31
+ options.profileDir ?? getChromeProfileDir(appDataDir),
32
+ ),
33
+ sessionsDir: getSessionsDir(appDataDir),
34
+ legacyTasksDir: getTasksDir(appDataDir),
35
+ };
36
+ }
37
+
38
+ async function assertDirectory(directory) {
39
+ const stat = await fs.stat(directory).catch(() => null);
40
+ if (!stat?.isDirectory()) {
41
+ throw new Error(`Project directory does not exist: ${directory}`);
42
+ }
43
+ }
44
+
45
+ async function runLogin(options) {
46
+ const { profileDir } = resolveRuntimePaths(options);
47
+ for (;;) {
48
+ console.log(`Opening native Chrome profile: ${profileDir}`);
49
+ console.log(
50
+ "This window has no CDP flags. Finish until ChatGPT shows your signed-in home/chat history and no Log in button.",
51
+ );
52
+ const browser = await launchNativeLoginBrowser({
53
+ profileDir,
54
+ chromePath: options.chromePath,
55
+ });
56
+
57
+ try {
58
+ await input({
59
+ message:
60
+ "After the signed-in ChatGPT home is visible, press Enter here to save and verify",
61
+ });
62
+ console.log("Closing native Chrome and saving the dedicated profile...");
63
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
64
+ } finally {
65
+ await browser.close();
66
+ }
67
+
68
+ const verifier = new ChatGPTWebAdapter({
69
+ profileDir,
70
+ chromePath: options.chromePath,
71
+ });
72
+ try {
73
+ await verifier.launch();
74
+ let authenticated = await verifier.getAuthState() === "authenticated";
75
+ if (!authenticated) {
76
+ try {
77
+ await verifier.waitForManualLogin({ timeoutMs: 8_000 });
78
+ authenticated = true;
79
+ } catch {
80
+ authenticated = false;
81
+ }
82
+ }
83
+ if (authenticated) {
84
+ console.log("ChatGPT login verified through a fresh CDP connection.");
85
+ return;
86
+ }
87
+ } finally {
88
+ await verifier.close();
89
+ }
90
+
91
+ console.log(
92
+ "ChatGPT is still in guest mode. Reopening native Chrome; complete the final ChatGPT sign-in/continue step.",
93
+ );
94
+ }
95
+ }
96
+
97
+ // Resets local login by deleting the dedicated Chrome profile. Login state for
98
+ // this app lives entirely in that profile (chatgpt.com cookies + localStorage),
99
+ // so removing it returns wtagent to a clean guest state — useful for testing the
100
+ // full login → run flow. It never touches the real account server-side.
101
+ async function runLogout(options) {
102
+ const { profileDir } = resolveRuntimePaths(options);
103
+ const exists = await fs.stat(profileDir)
104
+ .then((stat) => stat.isDirectory())
105
+ .catch(() => false);
106
+ if (!exists) {
107
+ console.log(`No Chrome profile found at ${profileDir}; already logged out.`);
108
+ return;
109
+ }
110
+
111
+ // Guard: only ever delete something that is actually the dedicated profile.
112
+ // A profile Chrome has used contains a "Default" profile directory; otherwise
113
+ // require the conventional "chrome-profile" basename before removing.
114
+ const looksLikeProfile = await fs.stat(path.join(profileDir, "Default"))
115
+ .then((stat) => stat.isDirectory())
116
+ .catch(() => false);
117
+ if (!looksLikeProfile && path.basename(profileDir) !== "chrome-profile") {
118
+ throw new Error(
119
+ `Refusing to delete ${profileDir}: it does not look like a wtagent Chrome profile.`,
120
+ );
121
+ }
122
+
123
+ if (!options.yes) {
124
+ const confirmed = await confirm({
125
+ message:
126
+ `This deletes the local ChatGPT session (Chrome profile at ${profileDir}) `
127
+ + "and requires a new login. Continue?",
128
+ default: false,
129
+ });
130
+ if (!confirmed) {
131
+ console.log("Logout cancelled.");
132
+ return;
133
+ }
134
+ }
135
+
136
+ await fs.rm(profileDir, { recursive: true, force: true });
137
+ console.log(`Logged out. Removed ${profileDir}.`);
138
+ console.log("Run `wtagent login` to sign in again.");
139
+ }
140
+
141
+ async function runDoctor(options) {
142
+ const paths = resolveRuntimePaths(options);
143
+ const chromePath = discoverChromeExecutable(options.chromePath);
144
+ await ensureDirectory(paths.appDataDir);
145
+ await ensureDirectory(paths.sessionsDir);
146
+
147
+ console.log(`Node: ${process.version}`);
148
+ console.log(`Platform: ${process.platform} ${process.arch}`);
149
+ console.log(`Chrome: ${chromePath}`);
150
+ console.log(`Data: ${paths.appDataDir}`);
151
+ console.log(`Profile: ${paths.profileDir}`);
152
+ console.log("Doctor: OK");
153
+ }
154
+
155
+ // Owns the browser adapter, process manager, and renderer for the lifetime of
156
+ // one conversation. A single instance drives many turns: the first turn boots
157
+ // the session, and each later turn reuses the same open Chrome tab and session
158
+ // state via runtime.run({ resume: true }).
159
+ class ConversationRunner {
160
+ constructor({ session, options }) {
161
+ this.session = session;
162
+ this.options = options;
163
+ this.paths = resolveRuntimePaths(options);
164
+ this.limits = resolveLimits({
165
+ modelTurnTimeoutMs: options.modelTurnTimeoutMs,
166
+ });
167
+ this.processManager = new ProcessManager();
168
+ this.renderer = createRenderer();
169
+ this.adapter = new ChatGPTWebAdapter({
170
+ profileDir: this.paths.profileDir,
171
+ chromePath: options.chromePath,
172
+ debug: options.debug,
173
+ // Minimize by default; `--no-minimize` sets options.minimize === false.
174
+ minimized: options.minimize !== false,
175
+ });
176
+ this.interrupted = false;
177
+ this.closed = false;
178
+ }
179
+
180
+ #buildRuntime() {
181
+ return new AgentRuntime({
182
+ adapter: this.adapter,
183
+ registry: createDefaultToolRegistry({
184
+ processManager: this.processManager,
185
+ limits: this.limits,
186
+ }),
187
+ policy: new PolicyEngine(),
188
+ session: this.session,
189
+ limits: this.limits,
190
+ approval: async ({ toolCall, reasons }) => {
191
+ this.renderer.stopSpinner();
192
+ console.log(`\n${"\x1b[33m"}Approval required for ${toolCall.name}:${"\x1b[0m"}`);
193
+ for (const reason of reasons) {
194
+ console.log(`- ${reason}`);
195
+ }
196
+ console.log(JSON.stringify(toolCall.args, null, 2));
197
+ return await confirm({
198
+ message: "Allow this action once?",
199
+ default: false,
200
+ });
201
+ },
202
+ onEvent: (event) => this.renderer.handle(event),
203
+ });
204
+ }
205
+
206
+ // Runs one turn. The first turn (resume=false) boots the session; later turns
207
+ // resume the same conversation with a new user instruction. `files` are
208
+ // resolved @file attachments for this turn's message.
209
+ async runTurn({ resume, instruction, files = [] }) {
210
+ const runtime = this.#buildRuntime();
211
+ try {
212
+ const result = await runtime.run({ resume, instruction, files });
213
+ return result;
214
+ } catch (error) {
215
+ if (this.interrupted) {
216
+ await this.session.update({
217
+ phase: "interrupted",
218
+ lastError: "Interrupted by user.",
219
+ });
220
+ await this.session.appendEvent("run.interrupted", {
221
+ message: "Interrupted by user.",
222
+ });
223
+ return null;
224
+ }
225
+ if (this.session.state.phase !== "idle") {
226
+ await this.session.update({
227
+ phase: "interrupted",
228
+ lastError: error.message,
229
+ });
230
+ await this.session.appendEvent("run.interrupted", {
231
+ message: error.message,
232
+ });
233
+ }
234
+ throw error;
235
+ }
236
+ }
237
+
238
+ async close() {
239
+ if (this.closed) {
240
+ return;
241
+ }
242
+ this.closed = true;
243
+ this.renderer.finish();
244
+ await this.processManager.stopAll().catch(() => {});
245
+ await this.adapter.close().catch(() => {});
246
+ }
247
+ }
248
+
249
+ async function executeSession({
250
+ session,
251
+ options,
252
+ resume = false,
253
+ instruction = null,
254
+ files = [],
255
+ }) {
256
+ const paths = resolveRuntimePaths(options);
257
+ await ensureDirectory(paths.sessionsDir);
258
+ const runner = new ConversationRunner({ session, options });
259
+
260
+ const onInterrupt = async () => {
261
+ if (runner.interrupted) {
262
+ process.exit(130);
263
+ }
264
+ runner.interrupted = true;
265
+ runner.renderer.stopSpinner();
266
+ console.log("\nStopping managed processes and Chrome…");
267
+ await runner.close();
268
+ process.exitCode = 130;
269
+ };
270
+ process.on("SIGINT", onInterrupt);
271
+
272
+ const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
273
+
274
+ try {
275
+ runner.renderer.hint(`Session ID: ${session.sessionId}`);
276
+ let turnResume = resume;
277
+ let turnInstruction = instruction;
278
+ let turnFiles = files;
279
+
280
+ for (;;) {
281
+ const result = await runner.runTurn({
282
+ resume: turnResume,
283
+ instruction: turnInstruction,
284
+ files: turnFiles,
285
+ });
286
+ if (runner.interrupted) {
287
+ break;
288
+ }
289
+ if (!interactive) {
290
+ return result;
291
+ }
292
+
293
+ // Managed dev servers keep running between turns; surface them once.
294
+ const running = runner.processManager.list().filter(
295
+ (item) => item.status === "running",
296
+ );
297
+ if (running.length > 0) {
298
+ runner.renderer.hint("Managed processes still running:");
299
+ for (const item of running) {
300
+ runner.renderer.hint(
301
+ ` ${item.processId} pid=${item.pid} ${item.detectedUrls.join(" ")}`,
302
+ );
303
+ }
304
+ }
305
+
306
+ const next = await promptForNextMessage(runner);
307
+ if (next == null) {
308
+ break;
309
+ }
310
+ await session.appendInstruction(next.text, { files: next.files });
311
+ turnResume = true;
312
+ turnInstruction = next.text;
313
+ turnFiles = next.files;
314
+ }
315
+ return null;
316
+ } finally {
317
+ process.removeListener("SIGINT", onInterrupt);
318
+ await runner.close();
319
+ console.log(`Session saved at: ${session.directory}`);
320
+ }
321
+ }
322
+
323
+ // Reads the user's next message from the interactive prompt. Returns null when
324
+ // the user wants to end the conversation (blank line, exit/quit, or EOF), or
325
+ // { text, files } where files are resolved @file attachments.
326
+ async function promptForNextMessage(runner) {
327
+ for (;;) {
328
+ let answer;
329
+ try {
330
+ answer = await input({
331
+ message: "you ›",
332
+ theme: { prefix: "" },
333
+ });
334
+ } catch {
335
+ // Ctrl+C / Ctrl+D inside the prompt ends the conversation cleanly.
336
+ runner.renderer.println("");
337
+ return null;
338
+ }
339
+ const text = String(answer ?? "").trim();
340
+ if (!text) {
341
+ return null;
342
+ }
343
+ if (["exit", "quit", ":q", "/exit", "/quit"].includes(text.toLowerCase())) {
344
+ return null;
345
+ }
346
+ const files = await resolveMessageAttachments(runner, text);
347
+ return { text, files };
348
+ }
349
+ }
350
+
351
+ // Parses @file mentions in a message, reports attached/missing files to the
352
+ // user, and returns the resolved attachment list.
353
+ async function resolveMessageAttachments(runner, text) {
354
+ const projectRoot = runner.session.state.projectRoot;
355
+ const { files, missing } = await extractAtMentions(text, projectRoot);
356
+ if (files.length > 0) {
357
+ runner.renderer.hint(
358
+ `Attaching: ${files.map((file) => file.name).join(", ")}`,
359
+ );
360
+ }
361
+ if (missing.length > 0) {
362
+ runner.renderer.hint(
363
+ `Not attached (${missing.map((m) => `${m.requested}: ${m.reason}`).join("; ")})`,
364
+ );
365
+ }
366
+ return files;
367
+ }
368
+
369
+ async function runAgent(taskParts, options) {
370
+ const projectRoot = path.resolve(options.project ?? process.cwd());
371
+ await assertDirectory(projectRoot);
372
+ const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
373
+
374
+ // In interactive mode an initial task is optional: the user can just start
375
+ // typing at the prompt. In one-shot mode a task is required.
376
+ let task = taskParts.join(" ").trim();
377
+ if (!task) {
378
+ if (interactive) {
379
+ printChatBanner(projectRoot, options.mode);
380
+ }
381
+ task = (await input({
382
+ message: interactive ? "you ›" : "Task",
383
+ theme: interactive ? { prefix: "" } : undefined,
384
+ validate: (value) => value.trim() ? true : "Please type a message.",
385
+ })).trim();
386
+ } else if (interactive) {
387
+ printChatBanner(projectRoot, options.mode);
388
+ }
389
+
390
+ const paths = resolveRuntimePaths(options);
391
+ await ensureDirectory(paths.sessionsDir);
392
+ const session = await AgentSession.create({
393
+ sessionsDir: paths.sessionsDir,
394
+ task,
395
+ projectRoot,
396
+ mode: options.mode,
397
+ });
398
+
399
+ // Resolve @file attachments in the opening task, if any. The task itself is
400
+ // already stored by AgentSession.create; here we only resolve the files to
401
+ // attach on the first turn (run() records them on the opening user item).
402
+ let files = [];
403
+ if (task) {
404
+ const { files: found, missing } = await extractAtMentions(task, projectRoot);
405
+ files = found;
406
+ if (found.length > 0) {
407
+ console.log(`Attaching: ${found.map((file) => file.name).join(", ")}`);
408
+ }
409
+ if (missing.length > 0) {
410
+ console.log(
411
+ `Not attached (${missing.map((m) => `${m.requested}: ${m.reason}`).join("; ")})`,
412
+ );
413
+ }
414
+ }
415
+
416
+ return await executeSession({ session, options, files });
417
+ }
418
+
419
+ function printChatBanner(projectRoot, mode) {
420
+ const CYAN = "\x1b[36m";
421
+ const DIM = "\x1b[2m";
422
+ const RESET = "\x1b[0m";
423
+ console.log("");
424
+ console.log(`${CYAN}WTAgent${RESET} ${DIM}· ChatGPT ${mode ?? "Pro"} · ${projectRoot}${RESET}`);
425
+ console.log(`${DIM}Type a message and press Enter. Blank line, "exit", or Ctrl+C to quit.${RESET}`);
426
+ console.log("");
427
+ }
428
+
429
+ async function loadSession(paths, sessionId) {
430
+ try {
431
+ return await AgentSession.load({
432
+ sessionsDir: paths.sessionsDir,
433
+ sessionId,
434
+ });
435
+ } catch (error) {
436
+ if (error.code !== "ENOENT") {
437
+ throw error;
438
+ }
439
+ return await AgentSession.load({
440
+ sessionsDir: paths.legacyTasksDir,
441
+ sessionId,
442
+ });
443
+ }
444
+ }
445
+
446
+ async function runResume(sessionId, instructionParts, options) {
447
+ const paths = resolveRuntimePaths(options);
448
+ await ensureDirectory(paths.sessionsDir);
449
+ const session = await loadSession(paths, sessionId);
450
+ await assertDirectory(session.state.projectRoot);
451
+
452
+ const instruction = instructionParts.join(" ").trim();
453
+ let files = [];
454
+ if (instruction) {
455
+ const projectRoot = session.state.projectRoot;
456
+ const { files: found, missing } = await extractAtMentions(instruction, projectRoot);
457
+ files = found;
458
+ if (found.length > 0) {
459
+ console.log(`Attaching: ${found.map((file) => file.name).join(", ")}`);
460
+ }
461
+ if (missing.length > 0) {
462
+ console.log(
463
+ `Not attached (${missing.map((m) => `${m.requested}: ${m.reason}`).join("; ")})`,
464
+ );
465
+ }
466
+ await session.appendInstruction(instruction, { files });
467
+ }
468
+
469
+ return await executeSession({
470
+ session,
471
+ options,
472
+ resume: true,
473
+ instruction: instruction || null,
474
+ files,
475
+ });
476
+ }
477
+
478
+ async function runStatus(sessionId, options) {
479
+ const paths = resolveRuntimePaths(options);
480
+ await ensureDirectory(paths.sessionsDir);
481
+ if (sessionId) {
482
+ const session = await loadSession(paths, sessionId);
483
+ console.log(JSON.stringify(session.state, null, 2));
484
+ return;
485
+ }
486
+
487
+ const currentSessions = await AgentSession.list({
488
+ sessionsDir: paths.sessionsDir,
489
+ });
490
+ const legacySessions = await AgentSession.list({
491
+ sessionsDir: paths.legacyTasksDir,
492
+ });
493
+ const sessions = [...currentSessions, ...legacySessions]
494
+ .filter((session, index, values) =>
495
+ values.findIndex((candidate) =>
496
+ candidate.sessionId === session.sessionId
497
+ ) === index
498
+ )
499
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
500
+ .slice(0, 20);
501
+ if (sessions.length === 0) {
502
+ console.log("No sessions found.");
503
+ return;
504
+ }
505
+
506
+ for (const session of sessions) {
507
+ const summary = session.task.replaceAll(/\s+/g, " ").slice(0, 72);
508
+ console.log(
509
+ `${session.sessionId}\t${session.phase}\tturn=${session.turn}\t${summary}`,
510
+ );
511
+ }
512
+ }
513
+
514
+ async function runExport(sessionId, options) {
515
+ const paths = resolveRuntimePaths(options);
516
+ await ensureDirectory(paths.sessionsDir);
517
+ const session = await loadSession(paths, sessionId);
518
+
519
+ const format = options.format ?? "codex";
520
+ const exporter = EXPORTERS[format];
521
+ if (!exporter) {
522
+ throw new Error(
523
+ `Unknown export format "${format}". Use one of: ${Object.keys(EXPORTERS).join(", ")}.`,
524
+ );
525
+ }
526
+
527
+ const transcript = await session.readTranscript();
528
+ if (transcript.items.length === 0) {
529
+ throw new Error(
530
+ `Session ${sessionId} has no canonical transcript to export.`,
531
+ );
532
+ }
533
+
534
+ const output = exporter(transcript, { sessionId: session.sessionId });
535
+ if (options.output) {
536
+ const target = path.resolve(options.output);
537
+ await fs.writeFile(target, output, { mode: 0o600 });
538
+ console.log(
539
+ `Exported ${transcript.items.length} items to ${target} (${format}).`,
540
+ );
541
+ } else {
542
+ process.stdout.write(output);
543
+ }
544
+ }
545
+
546
+ const program = new Command()
547
+ .name("wtagent")
548
+ .description("Turn your web AI session into a local tool-using agent.")
549
+ .version("0.1.0-alpha.0")
550
+ .option("--home <path>", "Application data directory")
551
+ .option("--profile-dir <path>", "Dedicated Chrome profile directory")
552
+ .option("--chrome-path <path>", "Chrome/Chromium executable")
553
+ .option("-C, --project <path>", "Project directory", process.cwd())
554
+ .option("--mode <name>", "ChatGPT mode to select", "Pro")
555
+ .option(
556
+ "--once",
557
+ "Run a single request and exit instead of a conversation",
558
+ false,
559
+ )
560
+ .option(
561
+ "--model-turn-timeout-ms <milliseconds>",
562
+ "Maximum wait for one ChatGPT response (default: 600000)",
563
+ )
564
+ .option(
565
+ "--no-minimize",
566
+ "Keep the Chrome window visible instead of minimizing it",
567
+ )
568
+ .option("--debug", "Write browser diagnostics", false)
569
+ .argument(
570
+ "[task...]",
571
+ "Initial request (optional; you can also type at the prompt)",
572
+ )
573
+ .action(async (task, _, command) => {
574
+ await runAgent(task, command.optsWithGlobals());
575
+ });
576
+
577
+ program
578
+ .command("doctor")
579
+ .description("Check Node, Chrome, and local data directories.")
580
+ .action(async (_, command) => runDoctor(command.optsWithGlobals()));
581
+
582
+ program
583
+ .command("login")
584
+ .description("Open the dedicated Chrome profile and wait for ChatGPT login.")
585
+ .action(async (_, command) => runLogin(command.optsWithGlobals()));
586
+
587
+ program
588
+ .command("logout")
589
+ .description("Delete the local Chrome profile to reset the ChatGPT session.")
590
+ .option("--yes", "Skip the confirmation prompt", false)
591
+ .action(async (options, command) => {
592
+ await runLogout({ ...command.optsWithGlobals(), ...options });
593
+ });
594
+
595
+ program
596
+ .command("resume")
597
+ .description("Continue an existing session or recover an interrupted run.")
598
+ .argument("<session-id>", "Saved session ID")
599
+ .argument("[instruction...]", "Optional follow-up instruction")
600
+ .action(async (sessionId, instruction, _, command) => {
601
+ await runResume(
602
+ sessionId,
603
+ instruction,
604
+ command.optsWithGlobals(),
605
+ );
606
+ });
607
+
608
+ program
609
+ .command("status")
610
+ .description("List saved sessions or show one session as JSON.")
611
+ .argument("[session-id]", "Saved session ID")
612
+ .action(async (sessionId, _, command) => {
613
+ await runStatus(sessionId, command.optsWithGlobals());
614
+ });
615
+
616
+ program
617
+ .command("export")
618
+ .description("Export a saved session to a Codex or Claude Code session.")
619
+ .argument("<session-id>", "Saved session ID")
620
+ .option("--format <name>", "codex or claude-code", "codex")
621
+ .option("-o, --output <path>", "Write to a file instead of stdout")
622
+ .action(async (sessionId, options, command) => {
623
+ await runExport(sessionId, { ...command.optsWithGlobals(), ...options });
624
+ });
625
+
626
+ program.parseAsync().catch((error) => {
627
+ console.error(error.stack ?? error.message);
628
+ process.exitCode = 1;
629
+ });