doer-agent 0.2.3 → 0.2.5

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.
Files changed (2) hide show
  1. package/dist/agent.js +2472 -408
  2. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { createWriteStream, existsSync, statSync } from "node:fs";
3
- import { chmod, mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { existsSync, statSync, watch } from "node:fs";
3
+ import { chmod, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { AckPolicy, connect, DeliverPolicy, JSONCodec, RetentionPolicy, StorageType, StringCodec } from "nats";
@@ -14,8 +14,17 @@ let workspaceRootOverride = null;
14
14
  const fsRpcCodec = StringCodec();
15
15
  const shellRpcCodec = StringCodec();
16
16
  const runRpcCodec = StringCodec();
17
+ const sessionRpcCodec = StringCodec();
18
+ const codexRpcCodec = StringCodec();
19
+ const codexAuthRpcCodec = StringCodec();
20
+ const settingsRpcCodec = StringCodec();
21
+ const gitRpcCodec = StringCodec();
17
22
  const activeRuns = new Map();
18
23
  const retainedRuns = new Map();
24
+ const activeSessionWatchers = new Map();
25
+ const sessionLineIndexCache = new Map();
26
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
27
+ let pendingCodexDeviceAuth = null;
19
28
  function sanitizeUserId(userId) {
20
29
  const normalized = userId.trim().replace(/[^a-zA-Z0-9_-]/g, "_");
21
30
  return normalized.length > 0 ? normalized : "anonymous";
@@ -23,6 +32,21 @@ function sanitizeUserId(userId) {
23
32
  function buildAgentRunRpcSubject(userId, agentId) {
24
33
  return `doer.agent.run.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
25
34
  }
35
+ function buildAgentSessionRpcSubject(userId, agentId) {
36
+ return `doer.agent.session.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
37
+ }
38
+ function buildAgentCodexRpcSubject(userId, agentId) {
39
+ return `doer.agent.codex.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
40
+ }
41
+ function buildAgentCodexAuthRpcSubject(userId, agentId) {
42
+ return `doer.agent.codex.auth.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
43
+ }
44
+ function buildAgentSettingsRpcSubject(userId, agentId) {
45
+ return `doer.agent.settings.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
46
+ }
47
+ function buildAgentGitRpcSubject(userId, agentId) {
48
+ return `doer.agent.git.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
49
+ }
26
50
  function normalizeNatsServers(value) {
27
51
  if (!Array.isArray(value)) {
28
52
  return [];
@@ -405,7 +429,6 @@ function normalizeRunRpcRequest(args) {
405
429
  throw new Error("missing runId");
406
430
  }
407
431
  const cwd = typeof args.request.cwd === "string" && args.request.cwd.trim() ? args.request.cwd.trim() : null;
408
- const chatId = typeof args.request.chatId === "string" && args.request.chatId.trim() ? args.request.chatId.trim() : null;
409
432
  const sinceSeqRaw = Number(args.request.sinceSeq);
410
433
  const sinceSeq = Number.isInteger(sinceSeqRaw) && sinceSeqRaw >= 0 ? sinceSeqRaw : null;
411
434
  const limitRaw = Number(args.request.limit);
@@ -416,7 +439,6 @@ function normalizeRunRpcRequest(args) {
416
439
  runId,
417
440
  command,
418
441
  cwd,
419
- chatId,
420
442
  responseSubject,
421
443
  sinceSeq,
422
444
  limit,
@@ -427,26 +449,472 @@ function normalizeRunRpcRequest(args) {
427
449
  function publishRunRpcResponse(args) {
428
450
  args.nc.publish(args.responseSubject, runRpcCodec.encode(JSON.stringify(args.payload)));
429
451
  }
430
- async function resolveRunLogsDir() {
452
+ async function resolveRunsDir() {
431
453
  const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
432
454
  const dir = path.join(workspaceRoot, ".doer-agent", "runs");
433
455
  await mkdir(dir, { recursive: true });
434
456
  return dir;
435
457
  }
436
- function cloneRunTask(task, sinceSeq) {
458
+ async function resetRunsDir() {
459
+ const dir = await resolveRunsDir();
460
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
461
+ await mkdir(dir, { recursive: true });
462
+ }
463
+ async function persistRunTask(task) {
464
+ const dir = await resolveRunsDir();
465
+ const payload = {
466
+ runId: task.id,
467
+ agentId: task.agentId,
468
+ userId: task.userId,
469
+ sessionId: task.sessionId,
470
+ sessionFilePath: task.sessionFilePath,
471
+ status: task.status,
472
+ cancelRequested: task.cancelRequested,
473
+ createdAt: task.createdAt,
474
+ updatedAt: task.updatedAt,
475
+ startedAt: task.startedAt,
476
+ finishedAt: task.finishedAt,
477
+ error: task.error,
478
+ };
479
+ await writeFile(path.join(dir, `${task.id}.json`), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
480
+ }
481
+ async function removeRunTask(runId) {
482
+ const dir = await resolveRunsDir();
483
+ await unlink(path.join(dir, `${runId}.json`)).catch(() => undefined);
484
+ }
485
+ function resolveAgentSettingsDir() {
486
+ const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
487
+ return path.join(workspaceRoot, ".doer-agent");
488
+ }
489
+ function resolveAgentSettingsFilePath() {
490
+ return path.join(resolveAgentSettingsDir(), "config.json");
491
+ }
492
+ function createDefaultAgentSettingsConfig() {
493
+ return {
494
+ general: {
495
+ firstTurnPrompt: null,
496
+ },
497
+ codex: {
498
+ model: "gpt-5.4",
499
+ authMode: "api_key",
500
+ apiKey: null,
501
+ },
502
+ realtime: {
503
+ model: process.env.OPENAI_REALTIME_MODEL?.trim() || "gpt-realtime",
504
+ voice: process.env.OPENAI_REALTIME_VOICE?.trim() || "alloy",
505
+ wakeName: null,
506
+ requireWakeName: true,
507
+ apiKey: null,
508
+ },
509
+ git: {
510
+ enabled: true,
511
+ name: null,
512
+ email: null,
513
+ authMode: "none",
514
+ oauthToken: null,
515
+ oauthLogin: null,
516
+ oauthScope: null,
517
+ },
518
+ aws: {
519
+ enabled: true,
520
+ accessKeyId: null,
521
+ defaultRegion: null,
522
+ secretAccessKey: null,
523
+ sessionToken: null,
524
+ },
525
+ jira: {
526
+ baseUrl: null,
527
+ email: null,
528
+ enabled: false,
529
+ apiToken: null,
530
+ },
531
+ notion: {
532
+ baseUrl: "https://api.notion.com",
533
+ version: "2022-06-28",
534
+ enabled: false,
535
+ apiToken: null,
536
+ },
537
+ slack: {
538
+ baseUrl: "https://slack.com/api",
539
+ enabled: false,
540
+ botToken: null,
541
+ },
542
+ figma: {
543
+ baseUrl: "https://api.figma.com",
544
+ enabled: false,
545
+ apiToken: null,
546
+ },
547
+ };
548
+ }
549
+ function normalizeNullableString(value) {
550
+ if (value === null) {
551
+ return null;
552
+ }
553
+ if (typeof value !== "string") {
554
+ return null;
555
+ }
556
+ const trimmed = value.trim();
557
+ return trimmed ? trimmed : null;
558
+ }
559
+ function normalizeAgentSettingsConfig(value, fallback) {
560
+ const base = fallback ?? createDefaultAgentSettingsConfig();
561
+ const raw = value && typeof value === "object" && !Array.isArray(value) ? value : {};
562
+ const general = raw.general && typeof raw.general === "object" ? raw.general : {};
563
+ const codex = raw.codex && typeof raw.codex === "object" ? raw.codex : {};
564
+ const realtime = raw.realtime && typeof raw.realtime === "object" ? raw.realtime : {};
565
+ const git = raw.git && typeof raw.git === "object" ? raw.git : {};
566
+ const aws = raw.aws && typeof raw.aws === "object" ? raw.aws : {};
567
+ const jira = raw.jira && typeof raw.jira === "object" ? raw.jira : {};
568
+ const notion = raw.notion && typeof raw.notion === "object" ? raw.notion : {};
569
+ const slack = raw.slack && typeof raw.slack === "object" ? raw.slack : {};
570
+ const figma = raw.figma && typeof raw.figma === "object" ? raw.figma : {};
571
+ return {
572
+ general: {
573
+ firstTurnPrompt: normalizeNullableString(general.firstTurnPrompt) ?? base.general.firstTurnPrompt,
574
+ },
575
+ codex: {
576
+ model: typeof codex.model === "string" && codex.model.trim() ? codex.model.trim() : base.codex.model,
577
+ authMode: codex.authMode === "oauth" ? "oauth" : codex.authMode === "api_key" ? "api_key" : base.codex.authMode,
578
+ apiKey: codex.apiKey === null ? null : normalizeNullableString(codex.apiKey) ?? base.codex.apiKey,
579
+ },
580
+ realtime: {
581
+ model: typeof realtime.model === "string" && realtime.model.trim() ? realtime.model.trim() : base.realtime.model,
582
+ voice: typeof realtime.voice === "string" && realtime.voice.trim() ? realtime.voice.trim() : base.realtime.voice,
583
+ wakeName: realtime.wakeName === null ? null : normalizeNullableString(realtime.wakeName) ?? base.realtime.wakeName,
584
+ requireWakeName: typeof realtime.requireWakeName === "boolean" ? realtime.requireWakeName : base.realtime.requireWakeName,
585
+ apiKey: realtime.apiKey === null ? null : normalizeNullableString(realtime.apiKey) ?? base.realtime.apiKey,
586
+ },
587
+ git: {
588
+ enabled: typeof git.enabled === "boolean" ? git.enabled : base.git.enabled,
589
+ name: git.name === null ? null : normalizeNullableString(git.name) ?? base.git.name,
590
+ email: git.email === null ? null : normalizeNullableString(git.email) ?? base.git.email,
591
+ authMode: git.authMode === "oauth_app" ? "oauth_app" : git.authMode === "none" ? "none" : base.git.authMode,
592
+ oauthToken: git.oauthToken === null ? null : normalizeNullableString(git.oauthToken) ?? base.git.oauthToken,
593
+ oauthLogin: git.oauthLogin === null ? null : normalizeNullableString(git.oauthLogin) ?? base.git.oauthLogin,
594
+ oauthScope: git.oauthScope === null ? null : normalizeNullableString(git.oauthScope) ?? base.git.oauthScope,
595
+ },
596
+ aws: {
597
+ enabled: typeof aws.enabled === "boolean" ? aws.enabled : base.aws.enabled,
598
+ accessKeyId: aws.accessKeyId === null ? null : normalizeNullableString(aws.accessKeyId) ?? base.aws.accessKeyId,
599
+ defaultRegion: aws.defaultRegion === null ? null : normalizeNullableString(aws.defaultRegion) ?? base.aws.defaultRegion,
600
+ secretAccessKey: aws.secretAccessKey === null ? null : normalizeNullableString(aws.secretAccessKey) ?? base.aws.secretAccessKey,
601
+ sessionToken: aws.sessionToken === null ? null : normalizeNullableString(aws.sessionToken) ?? base.aws.sessionToken,
602
+ },
603
+ jira: {
604
+ baseUrl: jira.baseUrl === null ? null : normalizeNullableString(jira.baseUrl) ?? base.jira.baseUrl,
605
+ email: jira.email === null ? null : normalizeNullableString(jira.email) ?? base.jira.email,
606
+ enabled: typeof jira.enabled === "boolean" ? jira.enabled : base.jira.enabled,
607
+ apiToken: jira.apiToken === null ? null : normalizeNullableString(jira.apiToken) ?? base.jira.apiToken,
608
+ },
609
+ notion: {
610
+ baseUrl: notion.baseUrl === null ? null : normalizeNullableString(notion.baseUrl) ?? base.notion.baseUrl,
611
+ version: notion.version === null ? null : normalizeNullableString(notion.version) ?? base.notion.version,
612
+ enabled: typeof notion.enabled === "boolean" ? notion.enabled : base.notion.enabled,
613
+ apiToken: notion.apiToken === null ? null : normalizeNullableString(notion.apiToken) ?? base.notion.apiToken,
614
+ },
615
+ slack: {
616
+ baseUrl: slack.baseUrl === null ? null : normalizeNullableString(slack.baseUrl) ?? base.slack.baseUrl,
617
+ enabled: typeof slack.enabled === "boolean" ? slack.enabled : base.slack.enabled,
618
+ botToken: slack.botToken === null ? null : normalizeNullableString(slack.botToken) ?? base.slack.botToken,
619
+ },
620
+ figma: {
621
+ baseUrl: figma.baseUrl === null ? null : normalizeNullableString(figma.baseUrl) ?? base.figma.baseUrl,
622
+ enabled: typeof figma.enabled === "boolean" ? figma.enabled : base.figma.enabled,
623
+ apiToken: figma.apiToken === null ? null : normalizeNullableString(figma.apiToken) ?? base.figma.apiToken,
624
+ },
625
+ };
626
+ }
627
+ async function readAgentSettingsConfig(defaults) {
628
+ const fallback = normalizeAgentSettingsConfig(defaults ?? null);
629
+ const filePath = resolveAgentSettingsFilePath();
630
+ const raw = await readFile(filePath, "utf8").catch(() => "");
631
+ if (!raw.trim()) {
632
+ return fallback;
633
+ }
634
+ try {
635
+ return normalizeAgentSettingsConfig(JSON.parse(raw), fallback);
636
+ }
637
+ catch {
638
+ return fallback;
639
+ }
640
+ }
641
+ async function writeAgentSettingsConfig(config) {
642
+ const dir = resolveAgentSettingsDir();
643
+ await mkdir(dir, { recursive: true });
644
+ await writeFile(resolveAgentSettingsFilePath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
645
+ }
646
+ function maskSecretPreview(secret) {
647
+ if (secret.length <= 6) {
648
+ return `${secret.slice(0, 1)}***${secret.slice(-1)}`;
649
+ }
650
+ return `${secret.slice(0, 4)}...${secret.slice(-4)}`;
651
+ }
652
+ function toMaskedSecret(value) {
653
+ if (!value) {
654
+ return { has: false, masked: null, length: null };
655
+ }
656
+ return { has: true, masked: maskSecretPreview(value), length: value.length };
657
+ }
658
+ function toAgentSettingsPublic(config) {
659
+ const codexKey = toMaskedSecret(config.codex.apiKey);
660
+ const realtimeKey = toMaskedSecret(config.realtime.apiKey);
661
+ const gitOauth = toMaskedSecret(config.git.oauthToken);
662
+ const awsSecret = toMaskedSecret(config.aws.secretAccessKey);
663
+ const awsSession = toMaskedSecret(config.aws.sessionToken);
664
+ const jiraToken = toMaskedSecret(config.jira.apiToken);
665
+ const notionToken = toMaskedSecret(config.notion.apiToken);
666
+ const slackToken = toMaskedSecret(config.slack.botToken);
667
+ const figmaToken = toMaskedSecret(config.figma.apiToken);
668
+ return {
669
+ general: {
670
+ firstTurnPrompt: config.general.firstTurnPrompt,
671
+ },
672
+ codex: {
673
+ model: config.codex.model,
674
+ authMode: config.codex.authMode,
675
+ hasApiKey: codexKey.has,
676
+ apiKeyMasked: codexKey.masked,
677
+ apiKeyLength: codexKey.length,
678
+ },
679
+ realtime: {
680
+ model: config.realtime.model,
681
+ voice: config.realtime.voice,
682
+ wakeName: config.realtime.wakeName,
683
+ requireWakeName: config.realtime.requireWakeName,
684
+ hasApiKey: realtimeKey.has,
685
+ apiKeyMasked: realtimeKey.masked,
686
+ apiKeyLength: realtimeKey.length,
687
+ },
688
+ git: {
689
+ enabled: config.git.enabled,
690
+ name: config.git.name,
691
+ email: config.git.email,
692
+ authMode: config.git.authMode,
693
+ hasOauthToken: gitOauth.has,
694
+ oauthTokenMasked: gitOauth.masked,
695
+ oauthTokenLength: gitOauth.length,
696
+ oauthLogin: config.git.oauthLogin,
697
+ oauthScope: config.git.oauthScope,
698
+ },
699
+ aws: {
700
+ enabled: config.aws.enabled,
701
+ accessKeyId: config.aws.accessKeyId,
702
+ defaultRegion: config.aws.defaultRegion,
703
+ hasSecretAccessKey: awsSecret.has,
704
+ secretAccessKeyMasked: awsSecret.masked,
705
+ secretAccessKeyLength: awsSecret.length,
706
+ hasSessionToken: awsSession.has,
707
+ sessionTokenMasked: awsSession.masked,
708
+ sessionTokenLength: awsSession.length,
709
+ },
710
+ jira: {
711
+ baseUrl: config.jira.baseUrl,
712
+ email: config.jira.email,
713
+ enabled: config.jira.enabled,
714
+ hasApiToken: jiraToken.has,
715
+ apiTokenMasked: jiraToken.masked,
716
+ apiTokenLength: jiraToken.length,
717
+ },
718
+ notion: {
719
+ baseUrl: config.notion.baseUrl,
720
+ version: config.notion.version,
721
+ enabled: config.notion.enabled,
722
+ hasApiToken: notionToken.has,
723
+ apiTokenMasked: notionToken.masked,
724
+ apiTokenLength: notionToken.length,
725
+ },
726
+ slack: {
727
+ baseUrl: config.slack.baseUrl,
728
+ enabled: config.slack.enabled,
729
+ hasBotToken: slackToken.has,
730
+ botTokenMasked: slackToken.masked,
731
+ botTokenLength: slackToken.length,
732
+ },
733
+ figma: {
734
+ baseUrl: config.figma.baseUrl,
735
+ enabled: config.figma.enabled,
736
+ hasApiToken: figmaToken.has,
737
+ apiTokenMasked: figmaToken.masked,
738
+ apiTokenLength: figmaToken.length,
739
+ },
740
+ };
741
+ }
742
+ function normalizeAgentSettingsPatch(value) {
743
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
744
+ return {};
745
+ }
746
+ const raw = value;
747
+ const patch = { ...raw };
748
+ const assignNested = (section, key, value) => {
749
+ const current = patch[section] && typeof patch[section] === "object" && !Array.isArray(patch[section])
750
+ ? ({ ...patch[section] })
751
+ : {};
752
+ current[key] = value;
753
+ patch[section] = current;
754
+ };
755
+ const move = (flatKey, section, key) => {
756
+ if (!(flatKey in raw)) {
757
+ return;
758
+ }
759
+ assignNested(section, key, raw[flatKey]);
760
+ delete patch[flatKey];
761
+ };
762
+ move("firstTurnPrompt", "general", "firstTurnPrompt");
763
+ move("codexModel", "codex", "model");
764
+ move("codexAuthMode", "codex", "authMode");
765
+ move("codexApiKey", "codex", "apiKey");
766
+ move("realtimeModel", "realtime", "model");
767
+ move("realtimeVoice", "realtime", "voice");
768
+ move("realtimeWakeName", "realtime", "wakeName");
769
+ move("realtimeRequireWakeName", "realtime", "requireWakeName");
770
+ move("realtimeApiKey", "realtime", "apiKey");
771
+ move("gitEnabled", "git", "enabled");
772
+ move("gitName", "git", "name");
773
+ move("gitEmail", "git", "email");
774
+ move("gitAuthMode", "git", "authMode");
775
+ move("gitOauthToken", "git", "oauthToken");
776
+ move("gitOauthLogin", "git", "oauthLogin");
777
+ move("gitOauthScope", "git", "oauthScope");
778
+ move("awsEnabled", "aws", "enabled");
779
+ move("awsAccessKeyId", "aws", "accessKeyId");
780
+ move("awsDefaultRegion", "aws", "defaultRegion");
781
+ move("awsSecretAccessKey", "aws", "secretAccessKey");
782
+ move("awsSessionToken", "aws", "sessionToken");
783
+ move("jiraBaseUrl", "jira", "baseUrl");
784
+ move("jiraEmail", "jira", "email");
785
+ move("jiraEnabled", "jira", "enabled");
786
+ move("jiraApiToken", "jira", "apiToken");
787
+ move("notionBaseUrl", "notion", "baseUrl");
788
+ move("notionVersion", "notion", "version");
789
+ move("notionEnabled", "notion", "enabled");
790
+ move("notionApiToken", "notion", "apiToken");
791
+ move("slackBaseUrl", "slack", "baseUrl");
792
+ move("slackEnabled", "slack", "enabled");
793
+ move("slackBotToken", "slack", "botToken");
794
+ move("figmaBaseUrl", "figma", "baseUrl");
795
+ move("figmaEnabled", "figma", "enabled");
796
+ move("figmaApiToken", "figma", "apiToken");
797
+ return patch;
798
+ }
799
+ async function resolveAgentSettingsConfig(args) {
800
+ const existing = await readAgentSettingsConfig(args.defaults ?? null);
801
+ const next = normalizeAgentSettingsConfig(args.patch ?? null, existing);
802
+ return next;
803
+ }
804
+ function buildAgentSettingsEnvPatch(config) {
805
+ const envPatch = {};
806
+ if (config.codex.authMode === "api_key" && config.codex.apiKey) {
807
+ envPatch.OPENAI_API_KEY = config.codex.apiKey;
808
+ }
809
+ if (config.git.enabled) {
810
+ if (config.git.name)
811
+ envPatch.GIT_AUTHOR_NAME = config.git.name;
812
+ if (config.git.name)
813
+ envPatch.GIT_COMMITTER_NAME = config.git.name;
814
+ if (config.git.email)
815
+ envPatch.GIT_AUTHOR_EMAIL = config.git.email;
816
+ if (config.git.email)
817
+ envPatch.GIT_COMMITTER_EMAIL = config.git.email;
818
+ if (config.git.oauthToken)
819
+ envPatch.GITHUB_TOKEN = config.git.oauthToken;
820
+ if (config.git.oauthToken)
821
+ envPatch.GH_TOKEN = config.git.oauthToken;
822
+ if (config.git.oauthLogin)
823
+ envPatch.DOER_GIT_OAUTH_LOGIN = config.git.oauthLogin;
824
+ if (config.git.oauthScope)
825
+ envPatch.DOER_GIT_OAUTH_SCOPE = config.git.oauthScope;
826
+ }
827
+ if (config.aws.enabled) {
828
+ if (config.aws.accessKeyId)
829
+ envPatch.AWS_ACCESS_KEY_ID = config.aws.accessKeyId;
830
+ if (config.aws.defaultRegion)
831
+ envPatch.AWS_DEFAULT_REGION = config.aws.defaultRegion;
832
+ if (config.aws.defaultRegion)
833
+ envPatch.AWS_REGION = config.aws.defaultRegion;
834
+ if (config.aws.secretAccessKey)
835
+ envPatch.AWS_SECRET_ACCESS_KEY = config.aws.secretAccessKey;
836
+ if (config.aws.sessionToken)
837
+ envPatch.AWS_SESSION_TOKEN = config.aws.sessionToken;
838
+ }
839
+ if (config.jira.enabled) {
840
+ if (config.jira.baseUrl)
841
+ envPatch.JIRA_BASE_URL = config.jira.baseUrl;
842
+ if (config.jira.email)
843
+ envPatch.JIRA_EMAIL = config.jira.email;
844
+ if (config.jira.apiToken)
845
+ envPatch.JIRA_API_TOKEN = config.jira.apiToken;
846
+ }
847
+ if (config.notion.enabled) {
848
+ if (config.notion.baseUrl)
849
+ envPatch.NOTION_BASE_URL = config.notion.baseUrl;
850
+ if (config.notion.version)
851
+ envPatch.NOTION_VERSION = config.notion.version;
852
+ if (config.notion.apiToken)
853
+ envPatch.NOTION_API_TOKEN = config.notion.apiToken;
854
+ }
855
+ if (config.slack.enabled) {
856
+ if (config.slack.baseUrl)
857
+ envPatch.SLACK_BASE_URL = config.slack.baseUrl;
858
+ if (config.slack.botToken)
859
+ envPatch.SLACK_BOT_TOKEN = config.slack.botToken;
860
+ }
861
+ if (config.figma.enabled) {
862
+ if (config.figma.baseUrl)
863
+ envPatch.FIGMA_BASE_URL = config.figma.baseUrl;
864
+ if (config.figma.apiToken)
865
+ envPatch.FIGMA_API_TOKEN = config.figma.apiToken;
866
+ }
867
+ return envPatch;
868
+ }
869
+ function cloneRunTask(task, _sinceSeq) {
437
870
  return {
438
871
  ...task,
439
- events: task.events
440
- .filter((event) => typeof sinceSeq === "number" ? event.seq > sinceSeq : true)
441
- .map((event) => ({ ...event, payload: { ...event.payload } })),
442
872
  };
443
873
  }
444
- function appendRunEvent(task, type, payload) {
445
- const timestamp = formatLocalTimestamp();
446
- const seq = task.agentEventAckSeq + 1;
447
- task.agentEventAckSeq = seq;
448
- task.updatedAt = timestamp;
449
- task.events.push({ seq, type, timestamp, payload });
874
+ function extractCodexSessionMetadata(value) {
875
+ try {
876
+ const parsed = JSON.parse(value);
877
+ const lineType = typeof parsed.type === "string" ? parsed.type : "";
878
+ if (!parsed.payload || typeof parsed.payload !== "object" || Array.isArray(parsed.payload)) {
879
+ return { sessionId: null, sessionFilePath: null };
880
+ }
881
+ const payload = parsed.payload;
882
+ const sessionIdCandidate = typeof payload.sessionId === "string" && payload.sessionId.trim()
883
+ ? payload.sessionId.trim()
884
+ : typeof payload.session_id === "string" && payload.session_id.trim()
885
+ ? payload.session_id.trim()
886
+ : typeof payload.id === "string" && payload.id.trim() && (lineType === "session_meta" || lineType === "session.started")
887
+ ? payload.id.trim()
888
+ : null;
889
+ const filePathCandidate = typeof payload.rollout_path === "string" && payload.rollout_path.trim()
890
+ ? payload.rollout_path.trim()
891
+ : typeof payload.filePath === "string" && payload.filePath.trim()
892
+ ? payload.filePath.trim()
893
+ : null;
894
+ return {
895
+ sessionId: sessionIdCandidate,
896
+ sessionFilePath: filePathCandidate,
897
+ };
898
+ }
899
+ catch {
900
+ return { sessionId: null, sessionFilePath: null };
901
+ }
902
+ }
903
+ async function updateRunSessionMetadata(task, metadata) {
904
+ let changed = false;
905
+ if (!task.sessionId && typeof metadata.sessionId === "string" && metadata.sessionId.trim()) {
906
+ task.sessionId = metadata.sessionId.trim();
907
+ changed = true;
908
+ }
909
+ if (!task.sessionFilePath && typeof metadata.sessionFilePath === "string" && metadata.sessionFilePath.trim()) {
910
+ task.sessionFilePath = metadata.sessionFilePath.trim();
911
+ changed = true;
912
+ }
913
+ if (!changed) {
914
+ return;
915
+ }
916
+ task.updatedAt = formatLocalTimestamp();
917
+ await persistRunTask(task).catch(() => undefined);
450
918
  }
451
919
  function persistRetainedRun(task) {
452
920
  retainedRuns.set(task.id, cloneRunTask(task));
@@ -461,7 +929,8 @@ function getStoredRun(runId) {
461
929
  async function startManagedRun(args) {
462
930
  const prepared = await prepareCommandExecution({
463
931
  cwd: args.cwd,
464
- runtimeEnvPatch: args.runtimeEnvPatch,
932
+ userId: args.userId,
933
+ taskId: args.runId,
465
934
  codexAuthBundle: args.codexAuthBundle,
466
935
  });
467
936
  const child = spawnPreparedCommand({
@@ -473,17 +942,13 @@ async function startManagedRun(args) {
473
942
  env: prepared.env,
474
943
  agentToken: args.agentToken,
475
944
  });
476
- const logsDir = await resolveRunLogsDir();
477
- const logPath = path.join(logsDir, `${args.runId}.log`);
478
- const logStream = createWriteStream(logPath, { flags: "a", encoding: "utf8" });
479
945
  const now = formatLocalTimestamp();
480
946
  const task = {
481
947
  id: args.runId,
482
948
  userId: args.userId,
483
949
  agentId: args.agentId,
484
- command: args.command,
485
- cwd: args.cwd,
486
- chatId: args.chatId,
950
+ sessionId: typeof args.sessionId === "string" && args.sessionId.trim() ? args.sessionId.trim() : null,
951
+ sessionFilePath: null,
487
952
  status: "running",
488
953
  cancelRequested: false,
489
954
  resultExitCode: null,
@@ -493,22 +958,7 @@ async function startManagedRun(args) {
493
958
  updatedAt: now,
494
959
  startedAt: now,
495
960
  finishedAt: null,
496
- agentEventAckSeq: 0,
497
- events: [],
498
961
  };
499
- appendRunEvent(task, "meta", {
500
- host: process.platform,
501
- pid: child.pid ?? null,
502
- startedAt: now,
503
- command: args.command,
504
- cwd: prepared.taskWorkspace,
505
- requestedCwd: args.cwd,
506
- shell: prepared.shellPath,
507
- logPath,
508
- ...prepared.taskGitMeta,
509
- ...prepared.codexAuthMeta,
510
- });
511
- appendRunEvent(task, "status", { status: "running" });
512
962
  const cancellation = createManagedCancellation(child);
513
963
  const requestCancel = () => {
514
964
  if (task.status === "completed" || task.status === "failed" || task.status === "canceled") {
@@ -516,13 +966,29 @@ async function startManagedRun(args) {
516
966
  }
517
967
  task.cancelRequested = true;
518
968
  task.updatedAt = formatLocalTimestamp();
969
+ void persistRunTask(task).catch(() => undefined);
519
970
  writeRunStatus(task.id, "cancel requested");
520
971
  cancellation.requestCancel();
521
972
  };
973
+ let stdoutBuffer = "";
522
974
  const recordChunk = (stream, chunk) => {
523
- appendRunEvent(task, stream, { chunk, at: formatLocalTimestamp() });
524
- logStream.write(JSON.stringify({ at: formatLocalTimestamp(), stream, chunk }) + "\n");
525
975
  writeRunStream(task.id, stream, chunk);
976
+ if (stream !== "stdout" || (task.sessionId && task.sessionFilePath)) {
977
+ return;
978
+ }
979
+ stdoutBuffer += chunk;
980
+ const lines = stdoutBuffer.split(/\r?\n/);
981
+ stdoutBuffer = lines.pop() ?? "";
982
+ for (const line of lines) {
983
+ const trimmed = line.trim();
984
+ if (!trimmed) {
985
+ continue;
986
+ }
987
+ const metadata = extractCodexSessionMetadata(trimmed);
988
+ if (metadata.sessionId || metadata.sessionFilePath) {
989
+ void updateRunSessionMetadata(task, metadata);
990
+ }
991
+ }
526
992
  };
527
993
  child.stdout.on("data", (chunk) => recordChunk("stdout", chunk));
528
994
  child.stderr.on("data", (chunk) => recordChunk("stderr", chunk));
@@ -531,452 +997,2017 @@ async function startManagedRun(args) {
531
997
  task.status = "failed";
532
998
  task.error = message;
533
999
  task.finishedAt = formatLocalTimestamp();
534
- appendRunEvent(task, "status", { status: "failed", error: message, finishedAt: task.finishedAt });
535
1000
  persistRetainedRun(task);
536
1001
  activeRuns.delete(task.id);
537
- logStream.end();
1002
+ void removeRunTask(task.id).catch(() => undefined);
538
1003
  void prepared.codexAuthCleanup().catch(() => undefined);
539
1004
  writeRunStatus(task.id, `failed error=${message}`);
540
1005
  });
541
1006
  child.once("close", (code, signal) => {
542
1007
  cancellation.clear();
1008
+ if (stdoutBuffer.trim() && (!task.sessionId || !task.sessionFilePath)) {
1009
+ const metadata = extractCodexSessionMetadata(stdoutBuffer.trim());
1010
+ if (metadata.sessionId || metadata.sessionFilePath) {
1011
+ void updateRunSessionMetadata(task, metadata);
1012
+ }
1013
+ }
543
1014
  task.resultExitCode = typeof code === "number" ? code : null;
544
1015
  task.resultSignal = signal;
545
1016
  task.finishedAt = formatLocalTimestamp();
546
1017
  task.status = task.cancelRequested ? "canceled" : (task.resultExitCode ?? 1) === 0 ? "completed" : "failed";
547
1018
  task.error = task.status === "failed" ? `Command exited with code ${task.resultExitCode ?? "null"}` : null;
548
- appendRunEvent(task, "status", {
549
- status: task.status,
550
- exitCode: task.resultExitCode,
551
- signal: task.resultSignal,
552
- error: task.error,
553
- finishedAt: task.finishedAt,
554
- });
555
1019
  persistRetainedRun(task);
556
1020
  activeRuns.delete(task.id);
557
- logStream.end();
1021
+ void removeRunTask(task.id).catch(() => undefined);
558
1022
  void prepared.codexAuthCleanup().catch(() => undefined);
559
1023
  writeRunStatus(task.id, `completed status=${task.status} exitCode=${task.resultExitCode ?? "null"} signal=${task.resultSignal ?? "null"}`);
560
1024
  });
561
- activeRuns.set(task.id, { task, child, logPath, logStream, requestCancel });
1025
+ activeRuns.set(task.id, { task, child, requestCancel });
562
1026
  persistRetainedRun(task);
1027
+ void persistRunTask(task).catch(() => undefined);
563
1028
  writeRunStatus(task.id, `started requestId=${args.requestId} cwd=${prepared.taskWorkspace}`);
564
1029
  return cloneRunTask(task);
565
1030
  }
566
- async function handleRunRpcMessage(args) {
1031
+ function shellSingleQuote(value) {
1032
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
1033
+ }
1034
+ function stripAnsi(value) {
1035
+ return value.replace(ANSI_RE, "");
1036
+ }
1037
+ function normalizeCodexModel(value) {
1038
+ const normalized = typeof value === "string" ? value.trim() : "";
1039
+ return normalized || "gpt-5.4";
1040
+ }
1041
+ function normalizeCodexRpcRequest(args) {
1042
+ const requestId = typeof args.request.requestId === "string" ? args.request.requestId.trim() : "";
1043
+ const responseSubject = typeof args.request.responseSubject === "string" ? args.request.responseSubject.trim() : "";
1044
+ const requestAgentId = typeof args.request.agentId === "string" ? args.request.agentId.trim() : "";
1045
+ const runId = typeof args.request.runId === "string" ? args.request.runId.trim() : "";
1046
+ const prompt = typeof args.request.prompt === "string" ? args.request.prompt.trim() : "";
1047
+ if (!requestId || !responseSubject || !requestAgentId || requestAgentId !== args.agentId || !runId || !prompt) {
1048
+ throw new Error("invalid codex rpc request");
1049
+ }
1050
+ return {
1051
+ requestId,
1052
+ responseSubject,
1053
+ runId,
1054
+ prompt,
1055
+ sessionId: typeof args.request.sessionId === "string" && args.request.sessionId.trim() ? args.request.sessionId.trim() : null,
1056
+ cwd: typeof args.request.cwd === "string" && args.request.cwd.trim() ? args.request.cwd.trim() : null,
1057
+ model: normalizeCodexModel(args.request.model),
1058
+ runtimeEnvPatch: normalizeEnvPatch(args.request.runtimeEnvPatch),
1059
+ codexAuthBundle: normalizeShellRpcCodexAuthBundle(args.request.codexAuth),
1060
+ };
1061
+ }
1062
+ function buildManagedCodexCommand(args) {
1063
+ const fixedArgs = ["--dangerously-bypass-approvals-and-sandbox"];
1064
+ const codexArgs = args.sessionId
1065
+ ? ["exec", "resume", "--json", args.sessionId, args.prompt]
1066
+ : ["exec", "--json", args.prompt];
1067
+ const commandArgs = [...fixedArgs, "--model", args.model, ...codexArgs].map(shellSingleQuote).join(" ");
1068
+ const direct = `exec codex ${commandArgs}`;
1069
+ const fallback = `exec npm exec --yes --package doer-agent -- codex ${commandArgs}`;
1070
+ const script = [
1071
+ "if command -v codex >/dev/null 2>&1; then",
1072
+ ` ${direct}`,
1073
+ "fi",
1074
+ fallback,
1075
+ ].join("\n");
1076
+ return `bash -lc ${shellSingleQuote(script)}`;
1077
+ }
1078
+ function publishCodexRpcResponse(args) {
1079
+ args.nc.publish(args.responseSubject, codexRpcCodec.encode(JSON.stringify(args.payload)));
1080
+ }
1081
+ function buildLocalCodexCliCommand(args) {
1082
+ const quotedArgs = args.map(shellSingleQuote).join(" ");
1083
+ const direct = `exec codex ${quotedArgs}`;
1084
+ const fallback = `exec npm exec --yes --package doer-agent -- codex ${quotedArgs}`;
1085
+ const script = [
1086
+ "if command -v codex >/dev/null 2>&1; then",
1087
+ ` ${direct}`,
1088
+ "fi",
1089
+ fallback,
1090
+ ].join("\n");
1091
+ return `bash -lc ${shellSingleQuote(script)}`;
1092
+ }
1093
+ async function runLocalCodexCli(args, timeoutMs) {
1094
+ const command = buildLocalCodexCliCommand(args);
1095
+ const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
1096
+ const env = {
1097
+ ...process.env,
1098
+ WORKSPACE: workspaceRoot,
1099
+ CODEX_HOME: resolveCodexHomePath(),
1100
+ };
1101
+ return await new Promise((resolve, reject) => {
1102
+ const child = spawn(command, {
1103
+ cwd: workspaceRoot,
1104
+ shell: resolveShellPath(),
1105
+ env,
1106
+ stdio: ["ignore", "pipe", "pipe"],
1107
+ });
1108
+ let stdout = "";
1109
+ let stderr = "";
1110
+ let done = false;
1111
+ let timedOut = false;
1112
+ child.stdout.setEncoding("utf8");
1113
+ child.stderr.setEncoding("utf8");
1114
+ child.stdout.on("data", (chunk) => {
1115
+ stdout += chunk;
1116
+ });
1117
+ child.stderr.on("data", (chunk) => {
1118
+ stderr += chunk;
1119
+ });
1120
+ const timer = setTimeout(() => {
1121
+ timedOut = true;
1122
+ sendSignalToTaskProcess(child, "SIGTERM");
1123
+ setTimeout(() => sendSignalToTaskProcess(child, "SIGKILL"), 1000);
1124
+ }, Math.max(500, timeoutMs));
1125
+ child.once("error", (error) => {
1126
+ if (done) {
1127
+ return;
1128
+ }
1129
+ done = true;
1130
+ clearTimeout(timer);
1131
+ reject(error);
1132
+ });
1133
+ child.once("exit", (code) => {
1134
+ if (done) {
1135
+ return;
1136
+ }
1137
+ done = true;
1138
+ clearTimeout(timer);
1139
+ resolve({ code, stdout, stderr, timedOut });
1140
+ });
1141
+ });
1142
+ }
1143
+ function parseCodexDeviceAuthOutput(raw) {
1144
+ const text = stripAnsi(raw);
1145
+ const urlMatch = text.match(/https?:\/\/[^\s]+/i);
1146
+ const codeMatch = text.match(/\b[A-Z0-9]{4,}(?:-[A-Z0-9]{4,})+\b/);
1147
+ return {
1148
+ verificationUri: urlMatch?.[0] ?? null,
1149
+ userCode: codeMatch?.[0] ?? null,
1150
+ };
1151
+ }
1152
+ function pendingCodexDeviceAuthMessage(state) {
1153
+ const parsed = parseCodexDeviceAuthOutput(state.output);
1154
+ if (parsed.verificationUri && parsed.userCode) {
1155
+ return `Waiting for approval. Enter code ${parsed.userCode} at ${parsed.verificationUri}`;
1156
+ }
1157
+ return stripAnsi(state.output).trim() || "Waiting for approval";
1158
+ }
1159
+ async function getLocalCodexLoginStatus() {
1160
+ const result = await runLocalCodexCli(["login", "status"], 5000);
1161
+ const merged = stripAnsi([result.stdout, result.stderr].filter(Boolean).join("\n")).trim();
1162
+ return {
1163
+ loggedIn: (result.code ?? 1) === 0,
1164
+ output: merged || ((result.code ?? 1) === 0 ? "Logged in" : "Not logged in"),
1165
+ };
1166
+ }
1167
+ async function waitForCodexDeviceCode(state, timeoutMs) {
1168
+ const startedAt = Date.now();
1169
+ while (Date.now() - startedAt < timeoutMs) {
1170
+ const parsed = parseCodexDeviceAuthOutput(state.output);
1171
+ if (parsed.verificationUri && parsed.userCode) {
1172
+ return;
1173
+ }
1174
+ if (state.child.exitCode !== null) {
1175
+ return;
1176
+ }
1177
+ await new Promise((resolve) => setTimeout(resolve, 100));
1178
+ }
1179
+ }
1180
+ function normalizeSettingsRpcRequest(args) {
1181
+ const requestId = typeof args.request.requestId === "string" ? args.request.requestId.trim() : "";
1182
+ const responseSubject = typeof args.request.responseSubject === "string" ? args.request.responseSubject.trim() : "";
1183
+ const requestAgentId = typeof args.request.agentId === "string" ? args.request.agentId.trim() : "";
1184
+ const action = args.request.action === "update" ? "update" : "get";
1185
+ if (!requestId || !responseSubject || !requestAgentId || requestAgentId !== args.agentId) {
1186
+ throw new Error("invalid settings rpc request");
1187
+ }
1188
+ return {
1189
+ requestId,
1190
+ responseSubject,
1191
+ action,
1192
+ patch: normalizeAgentSettingsPatch(args.request.patch),
1193
+ defaults: args.request.defaults && typeof args.request.defaults === "object" && !Array.isArray(args.request.defaults)
1194
+ ? normalizeAgentSettingsConfig(args.request.defaults)
1195
+ : null,
1196
+ };
1197
+ }
1198
+ function publishSettingsRpcResponse(args) {
1199
+ args.nc.publish(args.responseSubject, settingsRpcCodec.encode(JSON.stringify(args.payload)));
1200
+ }
1201
+ async function handleSettingsRpcMessage(args) {
567
1202
  let requestId = "unknown";
568
1203
  let responseSubject = "";
569
1204
  try {
570
- const payload = JSON.parse(runRpcCodec.decode(args.msg.data));
571
- const request = normalizeRunRpcRequest({ request: payload, agentId: args.agentId });
1205
+ const payload = JSON.parse(settingsRpcCodec.decode(args.msg.data));
1206
+ const request = normalizeSettingsRpcRequest({ request: payload, agentId: args.agentId });
572
1207
  requestId = request.requestId;
573
1208
  responseSubject = request.responseSubject;
574
- if (request.action === "start") {
575
- const task = await startManagedRun({
576
- requestId,
577
- runId: request.runId ?? requestId,
578
- userId: args.userId,
579
- agentId: args.agentId,
580
- command: request.command ?? "",
581
- cwd: request.cwd,
582
- chatId: request.chatId,
583
- runtimeEnvPatch: request.runtimeEnvPatch,
584
- codexAuthBundle: request.codexAuthBundle,
585
- agentToken: args.agentToken,
586
- });
587
- publishRunRpcResponse({ nc: args.jetstream.nc, responseSubject, payload: { requestId, ok: true, task } });
588
- return;
589
- }
590
- if (request.action === "list") {
591
- const tasks = [...activeRuns.values()].map((entry) => cloneRunTask(entry.task));
592
- const retained = [...retainedRuns.values()].filter((task) => !activeRuns.has(task.id)).map((task) => cloneRunTask(task));
593
- const merged = [...tasks, ...retained]
594
- .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
595
- .slice(0, request.limit);
596
- publishRunRpcResponse({ nc: args.jetstream.nc, responseSubject, payload: { requestId, ok: true, tasks: merged } });
597
- return;
598
- }
599
- const stored = request.runId ? getStoredRun(request.runId) : null;
600
- if (!stored || stored.agentId !== args.agentId || stored.userId !== args.userId) {
601
- throw new Error("Run not found");
1209
+ const existing = await readAgentSettingsConfig(request.defaults);
1210
+ const next = request.action === "update" ? normalizeAgentSettingsConfig(request.patch, existing) : existing;
1211
+ if (request.action === "update") {
1212
+ await writeAgentSettingsConfig(next);
602
1213
  }
603
- if (request.action === "cancel") {
604
- const active = activeRuns.get(stored.id);
605
- active?.requestCancel();
606
- const task = cloneRunTask(active?.task ?? stored);
607
- publishRunRpcResponse({ nc: args.jetstream.nc, responseSubject, payload: { requestId, ok: true, task } });
608
- return;
1214
+ else if (request.defaults) {
1215
+ const filePath = resolveAgentSettingsFilePath();
1216
+ const raw = await readFile(filePath, "utf8").catch(() => "");
1217
+ if (!raw.trim()) {
1218
+ await writeAgentSettingsConfig(next);
1219
+ }
609
1220
  }
610
- const task = cloneRunTask(stored, request.sinceSeq);
611
- publishRunRpcResponse({ nc: args.jetstream.nc, responseSubject, payload: { requestId, ok: true, task } });
1221
+ publishSettingsRpcResponse({
1222
+ nc: args.jetstream.nc,
1223
+ responseSubject,
1224
+ payload: {
1225
+ requestId,
1226
+ ok: true,
1227
+ settings: toAgentSettingsPublic(next),
1228
+ },
1229
+ });
612
1230
  }
613
1231
  catch (error) {
614
1232
  const message = error instanceof Error ? error.message : String(error);
615
1233
  if (responseSubject) {
616
- publishRunRpcResponse({
1234
+ publishSettingsRpcResponse({
617
1235
  nc: args.jetstream.nc,
618
1236
  responseSubject,
619
1237
  payload: { requestId, ok: false, error: message },
620
1238
  });
621
1239
  }
622
- writeAgentError(`run rpc failed requestId=${requestId} error=${message}`);
1240
+ writeAgentError(`settings rpc failed requestId=${requestId} error=${message}`);
623
1241
  }
624
1242
  }
625
- function subscribeToRunRpc(args) {
626
- const subject = buildAgentRunRpcSubject(args.userId, args.agentId);
1243
+ function subscribeToSettingsRpc(args) {
1244
+ const subject = buildAgentSettingsRpcSubject(args.userId, args.agentId);
627
1245
  args.jetstream.nc.subscribe(subject, {
628
1246
  callback: (error, msg) => {
629
1247
  if (error) {
630
1248
  const message = error instanceof Error ? error.message : String(error);
631
- writeAgentError(`run rpc subscription error: ${message}`);
1249
+ writeAgentError(`settings rpc subscription error: ${message}`);
632
1250
  return;
633
1251
  }
634
- void handleRunRpcMessage({ msg, jetstream: args.jetstream, userId: args.userId, agentId: args.agentId, agentToken: args.agentToken });
1252
+ void handleSettingsRpcMessage({
1253
+ msg,
1254
+ jetstream: args.jetstream,
1255
+ agentId: args.agentId,
1256
+ });
635
1257
  },
636
1258
  });
637
- writeAgentInfo(`run rpc subscribed subject=${subject}`);
638
- }
639
- function isLikelyNatsAuthError(error) {
640
- const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
641
- return (message.includes("auth")
642
- || message.includes("authorization")
643
- || message.includes("authentication")
644
- || message.includes("permission")
645
- || message.includes("jwt")
646
- || message.includes("token"));
647
- }
648
- function isLikelyNatsReconnectError(error) {
649
- const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
650
- return (message.includes("connection_closed")
651
- || message.includes("connection closed")
652
- || message.includes("closed connection")
653
- || message.includes("disconnected")
654
- || message.includes("timeout")
655
- || message.includes("no responders"));
1259
+ writeAgentInfo(`settings rpc subscribed subject=${subject}`);
656
1260
  }
657
- function sendSignalToTaskProcess(child, signal) {
658
- if (process.platform !== "win32" && typeof child.pid === "number") {
659
- try {
660
- // Detached child owns a process group; signal the whole group first.
661
- process.kill(-child.pid, signal);
662
- return;
663
- }
664
- catch {
665
- // Fall back to direct child signaling.
1261
+ async function startLocalCodexDeviceAuth() {
1262
+ if (pendingCodexDeviceAuth && pendingCodexDeviceAuth.child.exitCode === null) {
1263
+ const parsed = parseCodexDeviceAuthOutput(pendingCodexDeviceAuth.output);
1264
+ return {
1265
+ loggedIn: false,
1266
+ output: pendingCodexDeviceAuthMessage(pendingCodexDeviceAuth),
1267
+ verificationUri: parsed.verificationUri,
1268
+ userCode: parsed.userCode,
1269
+ };
1270
+ }
1271
+ const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
1272
+ const child = spawn(buildLocalCodexCliCommand(["login", "--device-auth"]), {
1273
+ cwd: workspaceRoot,
1274
+ shell: resolveShellPath(),
1275
+ detached: process.platform !== "win32",
1276
+ env: {
1277
+ ...process.env,
1278
+ WORKSPACE: workspaceRoot,
1279
+ CODEX_HOME: resolveCodexHomePath(),
1280
+ },
1281
+ stdio: ["ignore", "pipe", "pipe"],
1282
+ });
1283
+ child.stdout.setEncoding("utf8");
1284
+ child.stderr.setEncoding("utf8");
1285
+ const state = { child, output: "" };
1286
+ pendingCodexDeviceAuth = state;
1287
+ const appendOutput = (chunk) => {
1288
+ state.output += chunk;
1289
+ };
1290
+ child.stdout.on("data", appendOutput);
1291
+ child.stderr.on("data", appendOutput);
1292
+ child.once("exit", () => {
1293
+ if (pendingCodexDeviceAuth === state) {
1294
+ pendingCodexDeviceAuth = null;
666
1295
  }
1296
+ });
1297
+ await waitForCodexDeviceCode(state, 8000);
1298
+ const parsed = parseCodexDeviceAuthOutput(state.output);
1299
+ if ((!parsed.verificationUri || !parsed.userCode) && state.child.exitCode !== null) {
1300
+ throw new Error("Failed to read device code from Codex CLI");
667
1301
  }
668
- try {
1302
+ return {
1303
+ loggedIn: false,
1304
+ output: pendingCodexDeviceAuthMessage(state),
1305
+ verificationUri: parsed.verificationUri,
1306
+ userCode: parsed.userCode,
1307
+ };
1308
+ }
1309
+ async function startLocalCodexLogin() {
1310
+ const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
1311
+ const child = spawn(buildLocalCodexCliCommand(["login"]), {
1312
+ cwd: workspaceRoot,
1313
+ shell: resolveShellPath(),
1314
+ detached: process.platform !== "win32",
1315
+ env: {
1316
+ ...process.env,
1317
+ WORKSPACE: workspaceRoot,
1318
+ CODEX_HOME: resolveCodexHomePath(),
1319
+ },
1320
+ stdio: ["ignore", "pipe", "pipe"],
1321
+ });
1322
+ let output = "";
1323
+ child.stdout.setEncoding("utf8");
1324
+ child.stderr.setEncoding("utf8");
1325
+ child.stdout.on("data", (chunk) => {
1326
+ output += chunk;
1327
+ });
1328
+ child.stderr.on("data", (chunk) => {
1329
+ output += chunk;
1330
+ });
1331
+ const result = await new Promise((resolve, reject) => {
1332
+ child.once("error", reject);
1333
+ child.once("exit", (code) => resolve({ code, output }));
1334
+ });
1335
+ const normalized = stripAnsi(result.output).trim();
1336
+ const parsed = parseCodexDeviceAuthOutput(result.output);
1337
+ if ((result.code ?? 1) === 0) {
1338
+ const status = await getLocalCodexLoginStatus().catch(() => null);
1339
+ return {
1340
+ loggedIn: status?.loggedIn === true,
1341
+ output: status?.output || normalized || "Login started",
1342
+ verificationUri: parsed.verificationUri,
1343
+ userCode: parsed.userCode,
1344
+ };
1345
+ }
1346
+ throw new Error(normalized || `Codex login failed with code ${result.code ?? "null"}`);
1347
+ }
1348
+ async function logoutLocalCodexAuth() {
1349
+ if (pendingCodexDeviceAuth && pendingCodexDeviceAuth.child.exitCode === null) {
1350
+ sendSignalToTaskProcess(pendingCodexDeviceAuth.child, "SIGTERM");
1351
+ setTimeout(() => {
1352
+ if (pendingCodexDeviceAuth?.child.exitCode === null) {
1353
+ sendSignalToTaskProcess(pendingCodexDeviceAuth.child, "SIGKILL");
1354
+ }
1355
+ }, 1000);
1356
+ pendingCodexDeviceAuth = null;
1357
+ }
1358
+ const result = await runLocalCodexCli(["logout"], 5000);
1359
+ let merged = stripAnsi([result.stdout, result.stderr].filter(Boolean).join("\n")).trim();
1360
+ const statusAfterLogout = await getLocalCodexLoginStatus().catch(() => null);
1361
+ if (statusAfterLogout?.loggedIn) {
1362
+ const authFile = path.join(resolveCodexHomePath(), "auth.json");
1363
+ await unlink(authFile).catch(() => undefined);
1364
+ const statusAfterDelete = await getLocalCodexLoginStatus().catch(() => null);
1365
+ if (statusAfterDelete?.output) {
1366
+ merged = [merged, statusAfterDelete.output].filter(Boolean).join("\n");
1367
+ }
1368
+ }
1369
+ return {
1370
+ loggedIn: false,
1371
+ output: merged || "Logged out",
1372
+ };
1373
+ }
1374
+ function normalizeCodexAuthRpcRequest(args) {
1375
+ const requestId = typeof args.request.requestId === "string" ? args.request.requestId.trim() : "";
1376
+ const responseSubject = typeof args.request.responseSubject === "string" ? args.request.responseSubject.trim() : "";
1377
+ const requestAgentId = typeof args.request.agentId === "string" ? args.request.agentId.trim() : "";
1378
+ const actionRaw = typeof args.request.action === "string" ? args.request.action.trim() : "";
1379
+ const action = actionRaw === "start" || actionRaw === "logout" ? actionRaw : "status";
1380
+ if (!requestId || !responseSubject || !requestAgentId || requestAgentId !== args.agentId) {
1381
+ throw new Error("invalid codex auth rpc request");
1382
+ }
1383
+ return { requestId, responseSubject, action };
1384
+ }
1385
+ function publishCodexAuthRpcResponse(args) {
1386
+ args.nc.publish(args.responseSubject, codexAuthRpcCodec.encode(JSON.stringify(args.payload)));
1387
+ }
1388
+ async function handleCodexAuthRpcMessage(args) {
1389
+ let requestId = "unknown";
1390
+ let responseSubject = "";
1391
+ try {
1392
+ const payload = JSON.parse(codexAuthRpcCodec.decode(args.msg.data));
1393
+ const request = normalizeCodexAuthRpcRequest({ request: payload, agentId: args.agentId });
1394
+ requestId = request.requestId;
1395
+ responseSubject = request.responseSubject;
1396
+ let result = null;
1397
+ if (request.action === "start") {
1398
+ const status = await getLocalCodexLoginStatus();
1399
+ if (status.loggedIn) {
1400
+ result = { loggedIn: true, output: status.output };
1401
+ }
1402
+ else {
1403
+ try {
1404
+ result = await startLocalCodexDeviceAuth();
1405
+ }
1406
+ catch (error) {
1407
+ const message = error instanceof Error ? error.message : String(error);
1408
+ const normalized = message.toLowerCase();
1409
+ if (normalized.includes("operation not permitted") ||
1410
+ normalized.includes("failed to read device code") ||
1411
+ normalized.includes("panic") ||
1412
+ normalized.includes("null object")) {
1413
+ result = await startLocalCodexLogin();
1414
+ }
1415
+ else {
1416
+ throw error;
1417
+ }
1418
+ }
1419
+ }
1420
+ }
1421
+ else if (request.action === "logout") {
1422
+ result = await logoutLocalCodexAuth();
1423
+ }
1424
+ else {
1425
+ const status = await getLocalCodexLoginStatus();
1426
+ if (status.loggedIn) {
1427
+ result = { loggedIn: true, output: status.output };
1428
+ }
1429
+ else if (pendingCodexDeviceAuth && pendingCodexDeviceAuth.child.exitCode === null) {
1430
+ const parsed = parseCodexDeviceAuthOutput(pendingCodexDeviceAuth.output);
1431
+ result = {
1432
+ loggedIn: false,
1433
+ output: pendingCodexDeviceAuthMessage(pendingCodexDeviceAuth),
1434
+ verificationUri: parsed.verificationUri,
1435
+ userCode: parsed.userCode,
1436
+ };
1437
+ }
1438
+ else {
1439
+ result = { loggedIn: false, output: status.output || "Not logged in" };
1440
+ }
1441
+ }
1442
+ publishCodexAuthRpcResponse({
1443
+ nc: args.jetstream.nc,
1444
+ responseSubject,
1445
+ payload: {
1446
+ requestId,
1447
+ ok: true,
1448
+ loggedIn: result.loggedIn,
1449
+ output: result.output,
1450
+ verificationUri: result.verificationUri ?? null,
1451
+ userCode: result.userCode ?? null,
1452
+ },
1453
+ });
1454
+ }
1455
+ catch (error) {
1456
+ const message = error instanceof Error ? error.message : String(error);
1457
+ if (responseSubject) {
1458
+ publishCodexAuthRpcResponse({
1459
+ nc: args.jetstream.nc,
1460
+ responseSubject,
1461
+ payload: { requestId, ok: false, error: message },
1462
+ });
1463
+ }
1464
+ writeAgentError(`codex auth rpc failed requestId=${requestId} error=${message}`);
1465
+ }
1466
+ }
1467
+ function subscribeToCodexAuthRpc(args) {
1468
+ const subject = buildAgentCodexAuthRpcSubject(args.userId, args.agentId);
1469
+ args.jetstream.nc.subscribe(subject, {
1470
+ callback: (error, msg) => {
1471
+ if (error) {
1472
+ const message = error instanceof Error ? error.message : String(error);
1473
+ writeAgentError(`codex auth rpc subscription error: ${message}`);
1474
+ return;
1475
+ }
1476
+ void handleCodexAuthRpcMessage({
1477
+ msg,
1478
+ jetstream: args.jetstream,
1479
+ agentId: args.agentId,
1480
+ });
1481
+ },
1482
+ });
1483
+ writeAgentInfo(`codex auth rpc subscribed subject=${subject}`);
1484
+ }
1485
+ async function handleCodexRpcMessage(args) {
1486
+ let requestId = "unknown";
1487
+ let responseSubject = "";
1488
+ try {
1489
+ const payload = JSON.parse(codexRpcCodec.decode(args.msg.data));
1490
+ const request = normalizeCodexRpcRequest({ request: payload, agentId: args.agentId });
1491
+ requestId = request.requestId;
1492
+ responseSubject = request.responseSubject;
1493
+ const task = await startManagedRun({
1494
+ requestId,
1495
+ runId: request.runId,
1496
+ serverBaseUrl: args.serverBaseUrl,
1497
+ userId: args.userId,
1498
+ agentId: args.agentId,
1499
+ sessionId: request.sessionId,
1500
+ command: buildManagedCodexCommand({
1501
+ prompt: request.prompt,
1502
+ sessionId: request.sessionId,
1503
+ model: request.model,
1504
+ }),
1505
+ cwd: request.cwd,
1506
+ runtimeEnvPatch: request.runtimeEnvPatch,
1507
+ codexAuthBundle: request.codexAuthBundle,
1508
+ agentToken: args.agentToken,
1509
+ });
1510
+ publishCodexRpcResponse({
1511
+ nc: args.jetstream.nc,
1512
+ responseSubject,
1513
+ payload: { requestId, ok: true, task },
1514
+ });
1515
+ }
1516
+ catch (error) {
1517
+ const message = error instanceof Error ? error.message : String(error);
1518
+ if (responseSubject) {
1519
+ publishCodexRpcResponse({
1520
+ nc: args.jetstream.nc,
1521
+ responseSubject,
1522
+ payload: { requestId, ok: false, error: message },
1523
+ });
1524
+ }
1525
+ writeAgentError(`codex rpc failed requestId=${requestId} error=${message}`);
1526
+ }
1527
+ }
1528
+ function subscribeToCodexRpc(args) {
1529
+ const subject = buildAgentCodexRpcSubject(args.userId, args.agentId);
1530
+ args.jetstream.nc.subscribe(subject, {
1531
+ callback: (error, msg) => {
1532
+ if (error) {
1533
+ const message = error instanceof Error ? error.message : String(error);
1534
+ writeAgentError(`codex rpc subscription error: ${message}`);
1535
+ return;
1536
+ }
1537
+ void handleCodexRpcMessage({
1538
+ msg,
1539
+ jetstream: args.jetstream,
1540
+ serverBaseUrl: args.serverBaseUrl,
1541
+ userId: args.userId,
1542
+ agentId: args.agentId,
1543
+ agentToken: args.agentToken,
1544
+ });
1545
+ },
1546
+ });
1547
+ writeAgentInfo(`codex rpc subscribed subject=${subject}`);
1548
+ }
1549
+ function runLocalCommand(command, args, cwd) {
1550
+ return new Promise((resolve, reject) => {
1551
+ const child = spawn(command, args, {
1552
+ cwd,
1553
+ stdio: ["ignore", "pipe", "pipe"],
1554
+ });
1555
+ let stdout = "";
1556
+ let stderr = "";
1557
+ child.stdout.setEncoding("utf8");
1558
+ child.stderr.setEncoding("utf8");
1559
+ child.stdout.on("data", (chunk) => {
1560
+ stdout += chunk;
1561
+ });
1562
+ child.stderr.on("data", (chunk) => {
1563
+ stderr += chunk;
1564
+ });
1565
+ child.once("error", reject);
1566
+ child.once("close", (code) => {
1567
+ resolve({ code: code ?? 1, stdout, stderr });
1568
+ });
1569
+ });
1570
+ }
1571
+ function sanitizeGitRef(value) {
1572
+ if (typeof value !== "string" || !value.trim()) {
1573
+ return null;
1574
+ }
1575
+ const trimmed = value.trim();
1576
+ if (trimmed.startsWith("-") || /\s/.test(trimmed) || trimmed.includes("..") || trimmed.includes(":")) {
1577
+ throw new Error(`Invalid git ref: ${trimmed}`);
1578
+ }
1579
+ return trimmed;
1580
+ }
1581
+ function sanitizeGitPathspec(value) {
1582
+ if (typeof value !== "string") {
1583
+ throw new Error("Invalid pathspec");
1584
+ }
1585
+ const trimmed = value.trim().replace(/\\/g, "/");
1586
+ if (!trimmed || trimmed.startsWith("-") || trimmed.includes("\0")) {
1587
+ throw new Error(`Invalid pathspec: ${trimmed}`);
1588
+ }
1589
+ return trimmed;
1590
+ }
1591
+ function normalizeGitRpcRequest(args) {
1592
+ const requestId = typeof args.request.requestId === "string" ? args.request.requestId.trim() : "";
1593
+ const responseSubject = typeof args.request.responseSubject === "string" ? args.request.responseSubject.trim() : "";
1594
+ const requestAgentId = typeof args.request.agentId === "string" ? args.request.agentId.trim() : "";
1595
+ const targetPath = typeof args.request.targetPath === "string" ? args.request.targetPath.trim() : "";
1596
+ if (!requestId || !responseSubject || !requestAgentId || requestAgentId !== args.agentId || !targetPath) {
1597
+ throw new Error("invalid git rpc request");
1598
+ }
1599
+ const format = args.request.format === "name-only" ||
1600
+ args.request.format === "name-status" ||
1601
+ args.request.format === "stat" ||
1602
+ args.request.format === "numstat" ||
1603
+ args.request.format === "raw"
1604
+ ? args.request.format
1605
+ : "patch";
1606
+ const ignoreWhitespace = args.request.ignoreWhitespace === "at-eol" ||
1607
+ args.request.ignoreWhitespace === "change" ||
1608
+ args.request.ignoreWhitespace === "all"
1609
+ ? args.request.ignoreWhitespace
1610
+ : "none";
1611
+ const diffAlgorithm = args.request.diffAlgorithm === "minimal" ||
1612
+ args.request.diffAlgorithm === "patience" ||
1613
+ args.request.diffAlgorithm === "histogram"
1614
+ ? args.request.diffAlgorithm
1615
+ : "default";
1616
+ const contextRaw = Number(args.request.contextLines);
1617
+ const contextLines = Number.isFinite(contextRaw) ? Math.max(0, Math.min(200, Math.trunc(contextRaw))) : null;
1618
+ const pathspecs = Array.isArray(args.request.pathspecs) ? args.request.pathspecs.map((item) => sanitizeGitPathspec(item)) : [];
1619
+ return {
1620
+ requestId,
1621
+ responseSubject,
1622
+ targetPath,
1623
+ base: sanitizeGitRef(args.request.base),
1624
+ target: sanitizeGitRef(args.request.target),
1625
+ mergeBase: args.request.mergeBase === true,
1626
+ staged: args.request.staged === true,
1627
+ format,
1628
+ contextLines,
1629
+ ignoreWhitespace,
1630
+ diffAlgorithm,
1631
+ findRenames: args.request.findRenames === true,
1632
+ pathspecs,
1633
+ };
1634
+ }
1635
+ function buildAgentGitDiffArgs(repoRootAbs, request) {
1636
+ const args = ["-C", repoRootAbs, "diff", "--no-color"];
1637
+ const displayParts = ["git", "diff", "--no-color"];
1638
+ if (request.staged) {
1639
+ args.push("--cached");
1640
+ displayParts.push("--cached");
1641
+ }
1642
+ if (typeof request.contextLines === "number") {
1643
+ args.push(`-U${request.contextLines}`);
1644
+ displayParts.push(`-U${request.contextLines}`);
1645
+ }
1646
+ if (request.ignoreWhitespace === "at-eol") {
1647
+ args.push("--ignore-space-at-eol");
1648
+ displayParts.push("--ignore-space-at-eol");
1649
+ }
1650
+ else if (request.ignoreWhitespace === "change") {
1651
+ args.push("--ignore-space-change");
1652
+ displayParts.push("--ignore-space-change");
1653
+ }
1654
+ else if (request.ignoreWhitespace === "all") {
1655
+ args.push("--ignore-all-space");
1656
+ displayParts.push("--ignore-all-space");
1657
+ }
1658
+ if (request.diffAlgorithm !== "default") {
1659
+ args.push(`--diff-algorithm=${request.diffAlgorithm}`);
1660
+ displayParts.push(`--diff-algorithm=${request.diffAlgorithm}`);
1661
+ }
1662
+ if (request.findRenames) {
1663
+ args.push("--find-renames");
1664
+ displayParts.push("--find-renames");
1665
+ }
1666
+ if (request.format === "name-only") {
1667
+ args.push("--name-only");
1668
+ displayParts.push("--name-only");
1669
+ }
1670
+ else if (request.format === "name-status") {
1671
+ args.push("--name-status");
1672
+ displayParts.push("--name-status");
1673
+ }
1674
+ else if (request.format === "stat") {
1675
+ args.push("--stat");
1676
+ displayParts.push("--stat");
1677
+ }
1678
+ else if (request.format === "numstat") {
1679
+ args.push("--numstat");
1680
+ displayParts.push("--numstat");
1681
+ }
1682
+ else if (request.format === "raw") {
1683
+ args.push("--raw");
1684
+ displayParts.push("--raw");
1685
+ }
1686
+ if (request.mergeBase) {
1687
+ if (!request.base || !request.target) {
1688
+ throw new Error("mergeBase mode requires both base and target");
1689
+ }
1690
+ const merged = `${request.base}...${request.target}`;
1691
+ args.push(merged);
1692
+ displayParts.push(merged);
1693
+ }
1694
+ else {
1695
+ if (request.base) {
1696
+ args.push(request.base);
1697
+ displayParts.push(request.base);
1698
+ }
1699
+ if (request.target) {
1700
+ args.push(request.target);
1701
+ displayParts.push(request.target);
1702
+ }
1703
+ }
1704
+ if (request.pathspecs.length > 0) {
1705
+ args.push("--", ...request.pathspecs);
1706
+ displayParts.push("--", ...request.pathspecs);
1707
+ }
1708
+ return { args, display: displayParts.join(" ") };
1709
+ }
1710
+ function buildUntrackedText(format, untrackedPaths) {
1711
+ if (untrackedPaths.length === 0) {
1712
+ return "";
1713
+ }
1714
+ if (format === "name-status" || format === "raw") {
1715
+ return `${untrackedPaths.map((item) => `??\t${item}`).join("\n")}\n`;
1716
+ }
1717
+ if (format === "name-only") {
1718
+ return `${untrackedPaths.join("\n")}\n`;
1719
+ }
1720
+ return `\n# Untracked files\n${untrackedPaths.join("\n")}\n`;
1721
+ }
1722
+ async function appendAgentLocalUntrackedDiff(repoRootAbs, request, baseOutput) {
1723
+ const listArgs = ["-C", repoRootAbs, "ls-files", "--others", "--exclude-standard"];
1724
+ if (request.pathspecs.length > 0) {
1725
+ listArgs.push("--", ...request.pathspecs);
1726
+ }
1727
+ const listResult = await runLocalCommand("git", listArgs, repoRootAbs);
1728
+ if (listResult.code !== 0) {
1729
+ return { output: baseOutput, hasUntracked: false };
1730
+ }
1731
+ const untrackedPaths = listResult.stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
1732
+ if (untrackedPaths.length === 0) {
1733
+ return { output: baseOutput, hasUntracked: false };
1734
+ }
1735
+ if (request.format !== "patch") {
1736
+ return { output: `${baseOutput}${buildUntrackedText(request.format, untrackedPaths)}`, hasUntracked: true };
1737
+ }
1738
+ let output = baseOutput;
1739
+ for (const relPath of untrackedPaths) {
1740
+ const diffResult = await runLocalCommand("git", ["-C", repoRootAbs, "diff", "--no-color", "--no-index", "--", "/dev/null", relPath], repoRootAbs);
1741
+ if (diffResult.code !== 0 && diffResult.code !== 1) {
1742
+ throw new Error(diffResult.stderr.trim() || `Failed to render agent untracked diff: ${relPath}`);
1743
+ }
1744
+ if (diffResult.stdout) {
1745
+ output += diffResult.stdout;
1746
+ if (!output.endsWith("\n")) {
1747
+ output += "\n";
1748
+ }
1749
+ }
1750
+ }
1751
+ return { output, hasUntracked: true };
1752
+ }
1753
+ function publishGitRpcResponse(args) {
1754
+ args.nc.publish(args.responseSubject, gitRpcCodec.encode(JSON.stringify(args.payload)));
1755
+ }
1756
+ async function handleGitRpcMessage(args) {
1757
+ let requestId = "unknown";
1758
+ let responseSubject = "";
1759
+ try {
1760
+ const payload = JSON.parse(gitRpcCodec.decode(args.msg.data));
1761
+ const request = normalizeGitRpcRequest({ request: payload, agentId: args.agentId });
1762
+ requestId = request.requestId;
1763
+ responseSubject = request.responseSubject;
1764
+ if (!request.targetPath.startsWith("/")) {
1765
+ throw new Error("agent source requires an absolute directory path");
1766
+ }
1767
+ const topLevelResult = await runLocalCommand("git", ["-C", request.targetPath, "rev-parse", "--show-toplevel"], request.targetPath);
1768
+ if (topLevelResult.code !== 0) {
1769
+ publishGitRpcResponse({
1770
+ nc: args.jetstream.nc,
1771
+ responseSubject,
1772
+ payload: {
1773
+ requestId,
1774
+ ok: true,
1775
+ payload: {
1776
+ isGitRepo: false,
1777
+ mode: "git_diff",
1778
+ source: "agent",
1779
+ agent: { id: args.agentId, name: null },
1780
+ currentPath: request.targetPath,
1781
+ repoRoot: null,
1782
+ repoRelativePath: null,
1783
+ branch: null,
1784
+ gitDiff: {
1785
+ command: "git diff --no-color",
1786
+ format: "patch",
1787
+ output: "",
1788
+ outputTruncated: false,
1789
+ },
1790
+ message: "현재 경로가 Git 저장소가 아닙니다.",
1791
+ },
1792
+ },
1793
+ });
1794
+ return;
1795
+ }
1796
+ const repoRootAbs = topLevelResult.stdout.trim();
1797
+ const prefixResult = await runLocalCommand("git", ["-C", request.targetPath, "rev-parse", "--show-prefix"], request.targetPath);
1798
+ const repoRelativePath = prefixResult.code === 0 ? (prefixResult.stdout.trim().replace(/\/$/, "") || ".") : ".";
1799
+ const branchResult = await runLocalCommand("git", ["-C", repoRootAbs, "symbolic-ref", "--quiet", "--short", "HEAD"], repoRootAbs);
1800
+ const detachedResult = branchResult.code === 0 ? null : await runLocalCommand("git", ["-C", repoRootAbs, "rev-parse", "--short", "HEAD"], repoRootAbs);
1801
+ const branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : detachedResult && detachedResult.code === 0 ? detachedResult.stdout.trim() || null : null;
1802
+ const gitDiffArgs = buildAgentGitDiffArgs(repoRootAbs, request);
1803
+ const gitDiffResult = await runLocalCommand("git", gitDiffArgs.args, repoRootAbs);
1804
+ if (gitDiffResult.code !== 0) {
1805
+ throw new Error(gitDiffResult.stderr.trim() || "Failed to run agent git diff");
1806
+ }
1807
+ const withUntracked = await appendAgentLocalUntrackedDiff(repoRootAbs, request, gitDiffResult.stdout);
1808
+ publishGitRpcResponse({
1809
+ nc: args.jetstream.nc,
1810
+ responseSubject,
1811
+ payload: {
1812
+ requestId,
1813
+ ok: true,
1814
+ payload: {
1815
+ isGitRepo: true,
1816
+ mode: "git_diff",
1817
+ source: "agent",
1818
+ agent: { id: args.agentId, name: null },
1819
+ currentPath: request.targetPath,
1820
+ repoRoot: repoRootAbs,
1821
+ repoRelativePath,
1822
+ branch,
1823
+ gitDiff: {
1824
+ command: withUntracked.hasUntracked ? `${gitDiffArgs.display} (+ untracked)` : gitDiffArgs.display,
1825
+ format: request.format,
1826
+ output: withUntracked.output,
1827
+ outputTruncated: false,
1828
+ },
1829
+ },
1830
+ },
1831
+ });
1832
+ }
1833
+ catch (error) {
1834
+ const message = error instanceof Error ? error.message : String(error);
1835
+ if (responseSubject) {
1836
+ publishGitRpcResponse({
1837
+ nc: args.jetstream.nc,
1838
+ responseSubject,
1839
+ payload: { requestId, ok: false, error: message },
1840
+ });
1841
+ }
1842
+ writeAgentError(`git rpc failed requestId=${requestId} error=${message}`);
1843
+ }
1844
+ }
1845
+ function subscribeToGitRpc(args) {
1846
+ const subject = buildAgentGitRpcSubject(args.userId, args.agentId);
1847
+ args.jetstream.nc.subscribe(subject, {
1848
+ callback: (error, msg) => {
1849
+ if (error) {
1850
+ const message = error instanceof Error ? error.message : String(error);
1851
+ writeAgentError(`git rpc subscription error: ${message}`);
1852
+ return;
1853
+ }
1854
+ void handleGitRpcMessage({
1855
+ msg,
1856
+ jetstream: args.jetstream,
1857
+ userId: args.userId,
1858
+ agentId: args.agentId,
1859
+ });
1860
+ },
1861
+ });
1862
+ writeAgentInfo(`git rpc subscribed subject=${subject}`);
1863
+ }
1864
+ async function handleRunRpcMessage(args) {
1865
+ let requestId = "unknown";
1866
+ let responseSubject = "";
1867
+ try {
1868
+ const payload = JSON.parse(runRpcCodec.decode(args.msg.data));
1869
+ const request = normalizeRunRpcRequest({ request: payload, agentId: args.agentId });
1870
+ requestId = request.requestId;
1871
+ responseSubject = request.responseSubject;
1872
+ if (request.action === "start") {
1873
+ const task = await startManagedRun({
1874
+ requestId,
1875
+ runId: request.runId ?? requestId,
1876
+ serverBaseUrl: args.serverBaseUrl,
1877
+ userId: args.userId,
1878
+ agentId: args.agentId,
1879
+ sessionId: null,
1880
+ command: request.command ?? "",
1881
+ cwd: request.cwd,
1882
+ runtimeEnvPatch: request.runtimeEnvPatch,
1883
+ codexAuthBundle: request.codexAuthBundle,
1884
+ agentToken: args.agentToken,
1885
+ });
1886
+ publishRunRpcResponse({ nc: args.jetstream.nc, responseSubject, payload: { requestId, ok: true, task } });
1887
+ return;
1888
+ }
1889
+ if (request.action === "list") {
1890
+ const tasks = [...activeRuns.values()].map((entry) => cloneRunTask(entry.task));
1891
+ const retained = [...retainedRuns.values()].filter((task) => !activeRuns.has(task.id)).map((task) => cloneRunTask(task));
1892
+ const merged = [...tasks, ...retained]
1893
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
1894
+ .slice(0, request.limit);
1895
+ publishRunRpcResponse({ nc: args.jetstream.nc, responseSubject, payload: { requestId, ok: true, tasks: merged } });
1896
+ return;
1897
+ }
1898
+ const stored = request.runId ? getStoredRun(request.runId) : null;
1899
+ if (!stored || stored.agentId !== args.agentId || stored.userId !== args.userId) {
1900
+ throw new Error("Run not found");
1901
+ }
1902
+ if (request.action === "cancel") {
1903
+ const active = activeRuns.get(stored.id);
1904
+ active?.requestCancel();
1905
+ const task = cloneRunTask(active?.task ?? stored);
1906
+ publishRunRpcResponse({ nc: args.jetstream.nc, responseSubject, payload: { requestId, ok: true, task } });
1907
+ return;
1908
+ }
1909
+ const task = cloneRunTask(stored, request.sinceSeq);
1910
+ publishRunRpcResponse({ nc: args.jetstream.nc, responseSubject, payload: { requestId, ok: true, task } });
1911
+ }
1912
+ catch (error) {
1913
+ const message = error instanceof Error ? error.message : String(error);
1914
+ if (responseSubject) {
1915
+ publishRunRpcResponse({
1916
+ nc: args.jetstream.nc,
1917
+ responseSubject,
1918
+ payload: { requestId, ok: false, error: message },
1919
+ });
1920
+ }
1921
+ writeAgentError(`run rpc failed requestId=${requestId} error=${message}`);
1922
+ }
1923
+ }
1924
+ function subscribeToRunRpc(args) {
1925
+ const subject = buildAgentRunRpcSubject(args.userId, args.agentId);
1926
+ args.jetstream.nc.subscribe(subject, {
1927
+ callback: (error, msg) => {
1928
+ if (error) {
1929
+ const message = error instanceof Error ? error.message : String(error);
1930
+ writeAgentError(`run rpc subscription error: ${message}`);
1931
+ return;
1932
+ }
1933
+ void handleRunRpcMessage({
1934
+ msg,
1935
+ jetstream: args.jetstream,
1936
+ serverBaseUrl: args.serverBaseUrl,
1937
+ userId: args.userId,
1938
+ agentId: args.agentId,
1939
+ agentToken: args.agentToken,
1940
+ });
1941
+ },
1942
+ });
1943
+ writeAgentInfo(`run rpc subscribed subject=${subject}`);
1944
+ }
1945
+ function isLikelyNatsAuthError(error) {
1946
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1947
+ return (message.includes("auth")
1948
+ || message.includes("authorization")
1949
+ || message.includes("authentication")
1950
+ || message.includes("permission")
1951
+ || message.includes("jwt")
1952
+ || message.includes("token"));
1953
+ }
1954
+ function isLikelyNatsReconnectError(error) {
1955
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1956
+ return (message.includes("connection_closed")
1957
+ || message.includes("connection closed")
1958
+ || message.includes("closed connection")
1959
+ || message.includes("disconnected")
1960
+ || message.includes("timeout")
1961
+ || message.includes("no responders"));
1962
+ }
1963
+ function sendSignalToTaskProcess(child, signal) {
1964
+ if (process.platform !== "win32" && typeof child.pid === "number") {
1965
+ try {
1966
+ // Detached child owns a process group; signal the whole group first.
1967
+ process.kill(-child.pid, signal);
1968
+ return;
1969
+ }
1970
+ catch {
1971
+ // Fall back to direct child signaling.
1972
+ }
1973
+ }
1974
+ try {
669
1975
  child.kill(signal);
670
1976
  }
671
- catch {
672
- // noop
1977
+ catch {
1978
+ // noop
1979
+ }
1980
+ }
1981
+ function requestTaskCancellation(taskId, reason) {
1982
+ const requestCancel = activeTaskCancelRequests.get(taskId);
1983
+ if (!requestCancel) {
1984
+ return false;
1985
+ }
1986
+ try {
1987
+ requestCancel();
1988
+ writeAgentInfo(`task cancel requested taskId=${taskId} via=${reason}`);
1989
+ return true;
1990
+ }
1991
+ catch (error) {
1992
+ const message = error instanceof Error ? error.message : String(error);
1993
+ writeAgentError(`task cancel request failed taskId=${taskId} via=${reason}: ${message}`);
1994
+ return false;
1995
+ }
1996
+ }
1997
+ function resolveLogTimeZone() {
1998
+ const configured = process.env.DOER_AGENT_LOG_TIMEZONE?.trim() || process.env.TZ?.trim();
1999
+ return configured && configured.length > 0 ? configured : "Asia/Seoul";
2000
+ }
2001
+ function resolveTimeZoneOffsetString(date, timeZone) {
2002
+ try {
2003
+ const parts = new Intl.DateTimeFormat("en-US", {
2004
+ timeZone,
2005
+ timeZoneName: "shortOffset",
2006
+ hour: "2-digit",
2007
+ minute: "2-digit",
2008
+ hour12: false,
2009
+ }).formatToParts(date);
2010
+ const token = parts.find((part) => part.type === "timeZoneName")?.value || "GMT+0";
2011
+ const matched = token.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/i);
2012
+ if (!matched) {
2013
+ return "+00:00";
2014
+ }
2015
+ const hourRaw = matched[1] || "+0";
2016
+ const minuteRaw = matched[2] || "00";
2017
+ const sign = hourRaw.startsWith("-") ? "-" : "+";
2018
+ const absHour = String(Math.abs(Number.parseInt(hourRaw, 10))).padStart(2, "0");
2019
+ const absMinute = String(Math.abs(Number.parseInt(minuteRaw, 10))).padStart(2, "0");
2020
+ return `${sign}${absHour}:${absMinute}`;
2021
+ }
2022
+ catch {
2023
+ return "+00:00";
2024
+ }
2025
+ }
2026
+ function formatLocalTimestamp(date = new Date()) {
2027
+ const timeZone = resolveLogTimeZone();
2028
+ try {
2029
+ const parts = new Intl.DateTimeFormat("en-CA", {
2030
+ timeZone,
2031
+ year: "numeric",
2032
+ month: "2-digit",
2033
+ day: "2-digit",
2034
+ hour: "2-digit",
2035
+ minute: "2-digit",
2036
+ second: "2-digit",
2037
+ hour12: false,
2038
+ }).formatToParts(date);
2039
+ const pick = (type) => {
2040
+ return parts.find((part) => part.type === type)?.value || "00";
2041
+ };
2042
+ const year = pick("year");
2043
+ const month = pick("month");
2044
+ const day = pick("day");
2045
+ const hours = pick("hour");
2046
+ const minutes = pick("minute");
2047
+ const seconds = pick("second");
2048
+ const ms = String(date.getMilliseconds()).padStart(3, "0");
2049
+ const offset = resolveTimeZoneOffsetString(date, timeZone);
2050
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}${offset}`;
2051
+ }
2052
+ catch {
2053
+ return date.toISOString();
2054
+ }
2055
+ }
2056
+ function parseArgs(argv) {
2057
+ const out = {};
2058
+ for (let i = 0; i < argv.length; i += 1) {
2059
+ const key = argv[i];
2060
+ if (!key.startsWith("--")) {
2061
+ continue;
2062
+ }
2063
+ const value = argv[i + 1];
2064
+ if (typeof value === "string" && !value.startsWith("--")) {
2065
+ out[key.slice(2)] = value;
2066
+ i += 1;
2067
+ continue;
2068
+ }
2069
+ out[key.slice(2)] = "true";
2070
+ }
2071
+ return out;
2072
+ }
2073
+ function resolveArgOrEnv(args, argKeys, envKeys, fallback = "") {
2074
+ for (const key of argKeys) {
2075
+ const value = args[key]?.trim();
2076
+ if (value) {
2077
+ return value;
2078
+ }
2079
+ }
2080
+ for (const key of envKeys) {
2081
+ const value = process.env[key]?.trim();
2082
+ if (value) {
2083
+ return value;
2084
+ }
2085
+ }
2086
+ return fallback;
2087
+ }
2088
+ function resolveShellPath() {
2089
+ if (process.platform === "win32") {
2090
+ return process.env.ComSpec || "cmd.exe";
2091
+ }
2092
+ const candidates = [process.env.SHELL, "/bin/bash", "/usr/bin/bash", "/bin/sh", "/usr/bin/sh"].filter((value) => typeof value === "string" && value.trim().length > 0);
2093
+ for (const candidate of candidates) {
2094
+ if (existsSync(candidate)) {
2095
+ return candidate;
2096
+ }
2097
+ }
2098
+ throw new Error("No shell executable found. Set SHELL env or install /bin/sh (or bash).");
2099
+ }
2100
+ function resolveTaskWorkspace(rawCwd) {
2101
+ const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
2102
+ const requestedCwd = rawCwd?.trim() || "";
2103
+ const resolvedCwd = requestedCwd
2104
+ ? path.isAbsolute(requestedCwd)
2105
+ ? path.resolve(requestedCwd)
2106
+ : path.resolve(workspaceRoot, requestedCwd)
2107
+ : workspaceRoot;
2108
+ if (!existsSync(resolvedCwd)) {
2109
+ throw new Error(`Invalid cwd: ${requestedCwd || "(empty)"} resolved to ${resolvedCwd} (path does not exist)`);
2110
+ }
2111
+ let stats;
2112
+ try {
2113
+ stats = statSync(resolvedCwd);
2114
+ }
2115
+ catch (error) {
2116
+ const message = error instanceof Error ? error.message : String(error);
2117
+ throw new Error(`Invalid cwd: ${requestedCwd || "(empty)"} resolved to ${resolvedCwd} (${message})`);
2118
+ }
2119
+ if (!stats.isDirectory()) {
2120
+ throw new Error(`Invalid cwd: ${requestedCwd || "(empty)"} resolved to ${resolvedCwd} (not a directory)`);
2121
+ }
2122
+ return resolvedCwd;
2123
+ }
2124
+ function buildAgentFsRpcSubject(userId, agentId) {
2125
+ return `doer.agent.fs.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
2126
+ }
2127
+ function buildAgentShellRpcSubject(userId, agentId) {
2128
+ return `doer.agent.shell.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
2129
+ }
2130
+ function normalizeFsRpcPath(rawPath) {
2131
+ const root = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
2132
+ const raw = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
2133
+ const normalizedRaw = raw.replace(/\\/g, "/");
2134
+ const useAbsolute = path.isAbsolute(normalizedRaw);
2135
+ const rel = normalizedRaw.replace(/^\/+/, "") || ".";
2136
+ const abs = useAbsolute ? path.resolve(normalizedRaw) : path.resolve(root, rel);
2137
+ if (!useAbsolute && abs !== root && !abs.startsWith(root + path.sep)) {
2138
+ throw new Error("path escapes workspace root");
2139
+ }
2140
+ const formatPath = (target) => {
2141
+ if (useAbsolute) {
2142
+ return target.split(path.sep).join("/") || "/";
2143
+ }
2144
+ return path.relative(root, target).split(path.sep).join("/") || ".";
2145
+ };
2146
+ return { abs, formatPath };
2147
+ }
2148
+ function parseFsRpcAction(value) {
2149
+ if (value === "list" ||
2150
+ value === "stat" ||
2151
+ value === "fetch_file" ||
2152
+ value === "read_text" ||
2153
+ value === "read_file" ||
2154
+ value === "write_file" ||
2155
+ value === "download_file") {
2156
+ return value;
2157
+ }
2158
+ throw new Error("unsupported action");
2159
+ }
2160
+ function normalizeFsRpcNumber(value, fallback) {
2161
+ const n = Number(value);
2162
+ if (!Number.isFinite(n)) {
2163
+ return fallback;
2164
+ }
2165
+ return Math.floor(n);
2166
+ }
2167
+ function inferMimeType(filePath) {
2168
+ const ext = path.extname(filePath).toLowerCase();
2169
+ if (ext === ".txt" || ext === ".md" || ext === ".log") {
2170
+ return "text/plain";
2171
+ }
2172
+ if (ext === ".json") {
2173
+ return "application/json";
2174
+ }
2175
+ if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
2176
+ return "text/javascript";
2177
+ }
2178
+ if (ext === ".ts" || ext === ".tsx") {
2179
+ return "text/typescript";
2180
+ }
2181
+ if (ext === ".jsx") {
2182
+ return "text/jsx";
2183
+ }
2184
+ if (ext === ".css") {
2185
+ return "text/css";
2186
+ }
2187
+ if (ext === ".html" || ext === ".htm") {
2188
+ return "text/html";
2189
+ }
2190
+ if (ext === ".xml") {
2191
+ return "application/xml";
2192
+ }
2193
+ if (ext === ".svg") {
2194
+ return "image/svg+xml";
2195
+ }
2196
+ if (ext === ".png") {
2197
+ return "image/png";
2198
+ }
2199
+ if (ext === ".jpg" || ext === ".jpeg") {
2200
+ return "image/jpeg";
2201
+ }
2202
+ if (ext === ".gif") {
2203
+ return "image/gif";
2204
+ }
2205
+ if (ext === ".webp") {
2206
+ return "image/webp";
2207
+ }
2208
+ if (ext === ".pdf") {
2209
+ return "application/pdf";
2210
+ }
2211
+ return "application/octet-stream";
2212
+ }
2213
+ async function executeFsRpc(args) {
2214
+ const action = parseFsRpcAction(args.request.action);
2215
+ const { abs, formatPath } = normalizeFsRpcPath(args.request.path);
2216
+ if (action === "stat") {
2217
+ const entry = await stat(abs);
2218
+ return {
2219
+ ok: true,
2220
+ action,
2221
+ path: formatPath(abs),
2222
+ kind: entry.isDirectory() ? "dir" : "file",
2223
+ size: entry.size,
2224
+ mtimeMs: entry.mtimeMs,
2225
+ };
2226
+ }
2227
+ if (action === "list") {
2228
+ const entry = await stat(abs);
2229
+ if (!entry.isDirectory()) {
2230
+ throw new Error("path is not a directory");
2231
+ }
2232
+ const limit = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.limit, 200), 1000));
2233
+ const rows = await readdir(abs, { withFileTypes: true });
2234
+ const items = await Promise.all(rows.map(async (row) => {
2235
+ const child = path.join(abs, row.name);
2236
+ const childStat = await stat(child);
2237
+ return {
2238
+ name: row.name,
2239
+ path: formatPath(child),
2240
+ kind: row.isDirectory() ? "dir" : "file",
2241
+ size: childStat.size,
2242
+ mtimeMs: childStat.mtimeMs,
2243
+ };
2244
+ }));
2245
+ items.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1));
2246
+ return {
2247
+ ok: true,
2248
+ action,
2249
+ path: formatPath(abs),
2250
+ items: items.slice(0, limit),
2251
+ truncated: items.length > limit,
2252
+ total: items.length,
2253
+ };
2254
+ }
2255
+ if (action === "fetch_file") {
2256
+ const entry = await stat(abs);
2257
+ if (!entry.isFile()) {
2258
+ throw new Error("path is not a file");
2259
+ }
2260
+ const uploadUrl = typeof args.request.uploadUrl === "string" ? args.request.uploadUrl : "";
2261
+ const agentId = typeof args.request.agentId === "string" ? args.request.agentId : "";
2262
+ if (!uploadUrl || !agentId) {
2263
+ throw new Error("missing upload parameters");
2264
+ }
2265
+ const data = await readFile(abs);
2266
+ const fileName = path.basename(abs) || "file";
2267
+ const form = new FormData();
2268
+ form.append("file", new File([data], fileName));
2269
+ form.append("agentId", agentId);
2270
+ const response = await fetch(uploadUrl, {
2271
+ method: "POST",
2272
+ headers: { Authorization: `Bearer ${args.agentToken}` },
2273
+ body: form,
2274
+ });
2275
+ const text = await response.text();
2276
+ let upload = {};
2277
+ try {
2278
+ upload = JSON.parse(text || "{}");
2279
+ }
2280
+ catch {
2281
+ upload = {};
2282
+ }
2283
+ if (!response.ok) {
2284
+ const message = typeof upload.error === "string" ? upload.error : `upload failed: ${response.status}`;
2285
+ throw new Error(message);
2286
+ }
2287
+ return {
2288
+ ok: true,
2289
+ action,
2290
+ path: formatPath(abs),
2291
+ size: entry.size,
2292
+ upload,
2293
+ };
2294
+ }
2295
+ if (action === "write_file") {
2296
+ const contentBase64 = typeof args.request.contentBase64 === "string" ? args.request.contentBase64 : "";
2297
+ if (!contentBase64) {
2298
+ throw new Error("contentBase64 is required");
2299
+ }
2300
+ const parentDir = path.dirname(abs);
2301
+ await mkdir(parentDir, { recursive: true });
2302
+ const bytes = Buffer.from(contentBase64, "base64");
2303
+ await writeFile(abs, bytes);
2304
+ const entry = await stat(abs);
2305
+ return {
2306
+ ok: true,
2307
+ action,
2308
+ path: formatPath(abs),
2309
+ absolutePath: abs.split(path.sep).join("/"),
2310
+ size: entry.size,
2311
+ mimeType: inferMimeType(abs),
2312
+ mtimeMs: entry.mtimeMs,
2313
+ };
2314
+ }
2315
+ if (action === "download_file") {
2316
+ const downloadPath = typeof args.request.downloadPath === "string" ? args.request.downloadPath.trim() : "";
2317
+ if (!downloadPath) {
2318
+ throw new Error("downloadPath is required");
2319
+ }
2320
+ const downloadUrl = new URL(downloadPath, `${args.serverBaseUrl}/`).toString();
2321
+ const response = await fetch(downloadUrl, {
2322
+ method: "GET",
2323
+ headers: {
2324
+ Authorization: `Bearer ${args.agentToken}`,
2325
+ },
2326
+ });
2327
+ if (!response.ok) {
2328
+ const text = await response.text().catch(() => "");
2329
+ throw new Error(text || `download failed: ${response.status}`);
2330
+ }
2331
+ const bytes = Buffer.from(await response.arrayBuffer());
2332
+ const parentDir = path.dirname(abs);
2333
+ await mkdir(parentDir, { recursive: true });
2334
+ await writeFile(abs, bytes);
2335
+ const entry = await stat(abs);
2336
+ return {
2337
+ ok: true,
2338
+ action,
2339
+ path: formatPath(abs),
2340
+ absolutePath: abs.split(path.sep).join("/"),
2341
+ size: entry.size,
2342
+ mimeType: inferMimeType(abs),
2343
+ mtimeMs: entry.mtimeMs,
2344
+ };
2345
+ }
2346
+ const entry = await stat(abs);
2347
+ if (!entry.isFile()) {
2348
+ throw new Error("path is not a file");
2349
+ }
2350
+ if (action === "read_file") {
2351
+ const maxBytes = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.maxBytes, 2_000_000), 5_000_000));
2352
+ const data = await readFile(abs);
2353
+ const truncated = data.byteLength > maxBytes;
2354
+ const bytes = truncated ? data.subarray(0, maxBytes) : data;
2355
+ return {
2356
+ ok: true,
2357
+ action,
2358
+ path: formatPath(abs),
2359
+ mimeType: inferMimeType(abs),
2360
+ size: entry.size,
2361
+ truncated,
2362
+ contentBase64: bytes.toString("base64"),
2363
+ };
2364
+ }
2365
+ const offset = Math.max(0, normalizeFsRpcNumber(args.request.offset, 0));
2366
+ const length = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.length, 65536), 262144));
2367
+ const encoding = typeof args.request.encoding === "string" && args.request.encoding ? args.request.encoding : "utf8";
2368
+ const fd = await open(abs, "r");
2369
+ try {
2370
+ const buffer = Buffer.alloc(length);
2371
+ const readResult = await fd.read(buffer, 0, length, offset);
2372
+ const slice = buffer.subarray(0, readResult.bytesRead);
2373
+ try {
2374
+ const text = slice.toString(encoding);
2375
+ return {
2376
+ ok: true,
2377
+ action,
2378
+ path: formatPath(abs),
2379
+ offset,
2380
+ length: readResult.bytesRead,
2381
+ totalSize: entry.size,
2382
+ eof: offset + readResult.bytesRead >= entry.size,
2383
+ encoding,
2384
+ text,
2385
+ bytesRead: readResult.bytesRead,
2386
+ };
2387
+ }
2388
+ catch (error) {
2389
+ const message = error instanceof Error ? error.message : "failed to decode text";
2390
+ return {
2391
+ ok: false,
2392
+ action,
2393
+ path: formatPath(abs),
2394
+ error: message,
2395
+ };
2396
+ }
2397
+ }
2398
+ finally {
2399
+ await fd.close();
673
2400
  }
674
2401
  }
675
- function requestTaskCancellation(taskId, reason) {
676
- const requestCancel = activeTaskCancelRequests.get(taskId);
677
- if (!requestCancel) {
678
- return false;
2402
+ function normalizeSessionRpcRequest(args) {
2403
+ const requestId = typeof args.request.requestId === "string" ? args.request.requestId.trim() : "";
2404
+ if (!requestId) {
2405
+ throw new Error("missing requestId");
679
2406
  }
680
- try {
681
- requestCancel();
682
- writeAgentInfo(`task cancel requested taskId=${taskId} via=${reason}`);
683
- return true;
2407
+ const requestAgentId = typeof args.request.agentId === "string" ? args.request.agentId.trim() : "";
2408
+ if (!requestAgentId || requestAgentId !== args.agentId) {
2409
+ throw new Error("agent id mismatch");
684
2410
  }
685
- catch (error) {
686
- const message = error instanceof Error ? error.message : String(error);
687
- writeAgentError(`task cancel request failed taskId=${taskId} via=${reason}: ${message}`);
688
- return false;
2411
+ const actionRaw = typeof args.request.action === "string" ? args.request.action.trim() : "";
2412
+ const action = actionRaw === "messages" || actionRaw === "delete" || actionRaw === "watch" || actionRaw === "stop_watch"
2413
+ ? actionRaw
2414
+ : "list";
2415
+ const responseSubject = typeof args.request.responseSubject === "string" ? args.request.responseSubject.trim() : "";
2416
+ if (!responseSubject) {
2417
+ throw new Error("missing responseSubject");
2418
+ }
2419
+ const filePath = typeof args.request.filePath === "string" && args.request.filePath.trim() ? args.request.filePath.trim() : null;
2420
+ if ((action === "messages" || action === "delete" || action === "watch") && !filePath) {
2421
+ throw new Error("missing filePath");
689
2422
  }
2423
+ const sinceLineRaw = Number(args.request.sinceLine);
2424
+ const sinceLine = Number.isInteger(sinceLineRaw) && sinceLineRaw > 0 ? sinceLineRaw : 0;
2425
+ const beforeRowIdRaw = Number(args.request.beforeRowId);
2426
+ const beforeRowId = Number.isInteger(beforeRowIdRaw) && beforeRowIdRaw > 0 ? beforeRowIdRaw : null;
2427
+ const pageSizeRaw = Number(args.request.pageSize);
2428
+ const pageSize = Number.isFinite(pageSizeRaw) ? Math.max(1, Math.min(Math.floor(pageSizeRaw), 100)) : 100;
2429
+ const watchId = typeof args.request.watchId === "string" && args.request.watchId.trim() ? args.request.watchId.trim() : null;
2430
+ if (action === "stop_watch" && !watchId) {
2431
+ throw new Error("missing watchId");
2432
+ }
2433
+ return {
2434
+ requestId,
2435
+ action,
2436
+ agentId: requestAgentId,
2437
+ filePath,
2438
+ sessionId: typeof args.request.sessionId === "string" && args.request.sessionId.trim() ? args.request.sessionId.trim() : null,
2439
+ sinceLine,
2440
+ beforeRowId,
2441
+ pageSize,
2442
+ responseSubject,
2443
+ watchId,
2444
+ };
690
2445
  }
691
- function resolveLogTimeZone() {
692
- const configured = process.env.DOER_AGENT_LOG_TIMEZONE?.trim() || process.env.TZ?.trim();
693
- return configured && configured.length > 0 ? configured : "Asia/Seoul";
2446
+ function getSessionsRootPath() {
2447
+ const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
2448
+ return path.join(workspaceRoot, ".codex", "sessions");
694
2449
  }
695
- function resolveTimeZoneOffsetString(date, timeZone) {
696
- try {
697
- const parts = new Intl.DateTimeFormat("en-US", {
698
- timeZone,
699
- timeZoneName: "shortOffset",
700
- hour: "2-digit",
701
- minute: "2-digit",
702
- hour12: false,
703
- }).formatToParts(date);
704
- const token = parts.find((part) => part.type === "timeZoneName")?.value || "GMT+0";
705
- const matched = token.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/i);
706
- if (!matched) {
707
- return "+00:00";
708
- }
709
- const hourRaw = matched[1] || "+0";
710
- const minuteRaw = matched[2] || "00";
711
- const sign = hourRaw.startsWith("-") ? "-" : "+";
712
- const absHour = String(Math.abs(Number.parseInt(hourRaw, 10))).padStart(2, "0");
713
- const absMinute = String(Math.abs(Number.parseInt(minuteRaw, 10))).padStart(2, "0");
714
- return `${sign}${absHour}:${absMinute}`;
715
- }
716
- catch {
717
- return "+00:00";
2450
+ function resolveSessionFilePath(filePath) {
2451
+ const root = path.resolve(getSessionsRootPath());
2452
+ const resolved = path.resolve(filePath);
2453
+ if (!(resolved === root || resolved.startsWith(root + path.sep))) {
2454
+ throw new Error("filePath is outside sessions root");
718
2455
  }
2456
+ return resolved;
719
2457
  }
720
- function formatLocalTimestamp(date = new Date()) {
721
- const timeZone = resolveLogTimeZone();
722
- try {
723
- const parts = new Intl.DateTimeFormat("en-CA", {
724
- timeZone,
725
- year: "numeric",
726
- month: "2-digit",
727
- day: "2-digit",
728
- hour: "2-digit",
729
- minute: "2-digit",
730
- second: "2-digit",
731
- hour12: false,
732
- }).formatToParts(date);
733
- const pick = (type) => {
734
- return parts.find((part) => part.type === type)?.value || "00";
735
- };
736
- const year = pick("year");
737
- const month = pick("month");
738
- const day = pick("day");
739
- const hours = pick("hour");
740
- const minutes = pick("minute");
741
- const seconds = pick("second");
742
- const ms = String(date.getMilliseconds()).padStart(3, "0");
743
- const offset = resolveTimeZoneOffsetString(date, timeZone);
744
- return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}${offset}`;
2458
+ function isObjectRecord(value) {
2459
+ return !!value && typeof value === "object" && !Array.isArray(value);
2460
+ }
2461
+ function toTrimmedStringOrNull(value) {
2462
+ if (typeof value !== "string") {
2463
+ return null;
745
2464
  }
746
- catch {
747
- return date.toISOString();
2465
+ const trimmed = value.trim();
2466
+ return trimmed || null;
2467
+ }
2468
+ function pickSessionString(...values) {
2469
+ for (const value of values) {
2470
+ const picked = toTrimmedStringOrNull(value);
2471
+ if (picked) {
2472
+ return picked;
2473
+ }
748
2474
  }
2475
+ return null;
749
2476
  }
750
- function parseArgs(argv) {
751
- const out = {};
752
- for (let i = 0; i < argv.length; i += 1) {
753
- const key = argv[i];
754
- if (!key.startsWith("--")) {
2477
+ async function collectSessionJsonlFiles(rootDir) {
2478
+ const out = [];
2479
+ const stack = [rootDir];
2480
+ while (stack.length > 0) {
2481
+ const current = stack.pop();
2482
+ if (!current) {
755
2483
  continue;
756
2484
  }
757
- const value = argv[i + 1];
758
- if (typeof value === "string" && !value.startsWith("--")) {
759
- out[key.slice(2)] = value;
760
- i += 1;
2485
+ let entries = [];
2486
+ try {
2487
+ entries = await readdir(current, { withFileTypes: true });
2488
+ }
2489
+ catch {
761
2490
  continue;
762
2491
  }
763
- out[key.slice(2)] = "true";
2492
+ for (const entry of entries) {
2493
+ const fullPath = path.join(current, entry.name);
2494
+ if (entry.isDirectory()) {
2495
+ stack.push(fullPath);
2496
+ continue;
2497
+ }
2498
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".jsonl")) {
2499
+ continue;
2500
+ }
2501
+ try {
2502
+ const entryStat = await stat(fullPath);
2503
+ out.push({ filePath: fullPath, mtimeMs: entryStat.mtimeMs });
2504
+ }
2505
+ catch {
2506
+ // ignore removed files
2507
+ }
2508
+ }
764
2509
  }
765
2510
  return out;
766
2511
  }
767
- function resolveArgOrEnv(args, argKeys, envKeys, fallback = "") {
768
- for (const key of argKeys) {
769
- const value = args[key]?.trim();
770
- if (value) {
771
- return value;
2512
+ async function readFirstLine(fileHandle, fileSize) {
2513
+ const chunkBytes = 16_384;
2514
+ const maxScanBytes = 262_144;
2515
+ let position = 0;
2516
+ let scanned = 0;
2517
+ let raw = "";
2518
+ while (position < fileSize && scanned < maxScanBytes) {
2519
+ const readSize = Math.min(chunkBytes, fileSize - position, maxScanBytes - scanned);
2520
+ const buffer = Buffer.alloc(readSize);
2521
+ const { bytesRead } = await fileHandle.read(buffer, 0, readSize, position);
2522
+ if (bytesRead <= 0) {
2523
+ break;
2524
+ }
2525
+ raw += buffer.toString("utf8", 0, bytesRead);
2526
+ scanned += bytesRead;
2527
+ position += bytesRead;
2528
+ const newlineIndex = raw.search(/\r?\n/);
2529
+ if (newlineIndex >= 0) {
2530
+ return raw.slice(0, newlineIndex).trim();
772
2531
  }
773
2532
  }
774
- for (const key of envKeys) {
775
- const value = process.env[key]?.trim();
776
- if (value) {
777
- return value;
2533
+ return raw.trim();
2534
+ }
2535
+ function extractLastAgentMessage(candidateLines) {
2536
+ let fallback = null;
2537
+ for (const line of candidateLines) {
2538
+ const trimmed = line.trim();
2539
+ if (!trimmed) {
2540
+ continue;
2541
+ }
2542
+ try {
2543
+ const parsed = JSON.parse(trimmed);
2544
+ if (parsed.type !== "event_msg" || !isObjectRecord(parsed.payload)) {
2545
+ continue;
2546
+ }
2547
+ if (parsed.payload.type === "agent_message" && typeof parsed.payload.message === "string" && parsed.payload.message.trim()) {
2548
+ return {
2549
+ message: parsed.payload.message.trim(),
2550
+ updatedAt: toTrimmedStringOrNull(parsed.timestamp),
2551
+ };
2552
+ }
2553
+ if (!fallback &&
2554
+ parsed.payload.type === "task_complete" &&
2555
+ typeof parsed.payload.last_agent_message === "string" &&
2556
+ parsed.payload.last_agent_message.trim()) {
2557
+ fallback = {
2558
+ message: parsed.payload.last_agent_message.trim(),
2559
+ updatedAt: toTrimmedStringOrNull(parsed.timestamp),
2560
+ };
2561
+ }
2562
+ }
2563
+ catch {
2564
+ // ignore malformed lines
778
2565
  }
779
2566
  }
780
2567
  return fallback;
781
2568
  }
782
- function resolveShellPath() {
783
- if (process.platform === "win32") {
784
- return process.env.ComSpec || "cmd.exe";
2569
+ async function readLastAgentMessage(fileHandle, fileSize) {
2570
+ const chunkBytes = 16_384;
2571
+ const maxScanBytes = 131_072;
2572
+ if (fileSize <= 0) {
2573
+ return null;
785
2574
  }
786
- const candidates = [process.env.SHELL, "/bin/bash", "/usr/bin/bash", "/bin/sh", "/usr/bin/sh"].filter((value) => typeof value === "string" && value.trim().length > 0);
787
- for (const candidate of candidates) {
788
- if (existsSync(candidate)) {
789
- return candidate;
2575
+ let position = fileSize;
2576
+ let scanned = 0;
2577
+ let carry = "";
2578
+ while (position > 0 && scanned < maxScanBytes) {
2579
+ const readSize = Math.min(chunkBytes, position, maxScanBytes - scanned);
2580
+ position -= readSize;
2581
+ scanned += readSize;
2582
+ const buffer = Buffer.alloc(readSize);
2583
+ const { bytesRead } = await fileHandle.read(buffer, 0, readSize, position);
2584
+ if (bytesRead <= 0) {
2585
+ break;
2586
+ }
2587
+ const merged = buffer.toString("utf8", 0, bytesRead) + carry;
2588
+ const lines = merged.split(/\r?\n/);
2589
+ carry = lines.shift() || "";
2590
+ const found = extractLastAgentMessage(lines.reverse());
2591
+ if (found) {
2592
+ return found;
790
2593
  }
791
2594
  }
792
- throw new Error("No shell executable found. Set SHELL env or install /bin/sh (or bash).");
2595
+ return extractLastAgentMessage([carry]);
793
2596
  }
794
- function resolveTaskWorkspace(rawCwd) {
795
- const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
796
- const requestedCwd = rawCwd?.trim() || "";
797
- const resolvedCwd = requestedCwd
798
- ? path.isAbsolute(requestedCwd)
799
- ? path.resolve(requestedCwd)
800
- : path.resolve(workspaceRoot, requestedCwd)
801
- : workspaceRoot;
802
- if (!existsSync(resolvedCwd)) {
803
- throw new Error(`Invalid cwd: ${requestedCwd || "(empty)"} resolved to ${resolvedCwd} (path does not exist)`);
804
- }
805
- let stats;
2597
+ function normalizeSessionMeta(rawMeta, filePath, mtimeMs) {
2598
+ const baseName = path.basename(filePath, path.extname(filePath));
2599
+ const meta = isObjectRecord(rawMeta) ? rawMeta : {};
2600
+ const updatedAtCandidate = pickSessionString(meta.updatedAt, meta.updated_at, meta.timestamp);
2601
+ return {
2602
+ id: pickSessionString(meta.sessionId, meta.session_id, meta.id) || baseName,
2603
+ label: pickSessionString(meta.label, meta.title, meta.name, meta.sessionLabel, meta.session_label) || baseName,
2604
+ updatedAt: updatedAtCandidate || new Date(mtimeMs).toISOString(),
2605
+ cwd: pickSessionString(meta.cwd, meta.workingDirectory, meta.working_directory),
2606
+ source: pickSessionString(meta.source, meta.sessionSource, meta.session_source) || "codex",
2607
+ originator: pickSessionString(meta.originator, meta.author, meta.user, meta.username) || "unknown",
2608
+ filePath,
2609
+ };
2610
+ }
2611
+ async function readSessionSummary(filePath, mtimeMs) {
2612
+ let fileHandle = null;
806
2613
  try {
807
- stats = statSync(resolvedCwd);
2614
+ fileHandle = await open(filePath, "r");
2615
+ const entryStat = await fileHandle.stat();
2616
+ const firstLine = await readFirstLine(fileHandle, entryStat.size);
2617
+ const tailSummary = await readLastAgentMessage(fileHandle, entryStat.size);
2618
+ let normalized = normalizeSessionMeta({}, filePath, mtimeMs);
2619
+ if (firstLine) {
2620
+ try {
2621
+ const parsed = JSON.parse(firstLine);
2622
+ const candidateMeta = parsed && parsed.type === "session_meta" && isObjectRecord(parsed.payload)
2623
+ ? parsed.payload
2624
+ : isObjectRecord(parsed.session_meta)
2625
+ ? parsed.session_meta
2626
+ : isObjectRecord(parsed.sessionMeta)
2627
+ ? parsed.sessionMeta
2628
+ : isObjectRecord(parsed.meta)
2629
+ ? parsed.meta
2630
+ : isObjectRecord(parsed.payload)
2631
+ ? parsed.payload
2632
+ : parsed;
2633
+ normalized = normalizeSessionMeta(candidateMeta, filePath, mtimeMs);
2634
+ }
2635
+ catch {
2636
+ normalized = normalizeSessionMeta({}, filePath, mtimeMs);
2637
+ }
2638
+ }
2639
+ return {
2640
+ ...normalized,
2641
+ label: tailSummary?.message || "(no agent message)",
2642
+ updatedAt: tailSummary?.updatedAt || normalized.updatedAt,
2643
+ };
808
2644
  }
809
- catch (error) {
810
- const message = error instanceof Error ? error.message : String(error);
811
- throw new Error(`Invalid cwd: ${requestedCwd || "(empty)"} resolved to ${resolvedCwd} (${message})`);
2645
+ catch {
2646
+ return normalizeSessionMeta({}, filePath, mtimeMs);
812
2647
  }
813
- if (!stats.isDirectory()) {
814
- throw new Error(`Invalid cwd: ${requestedCwd || "(empty)"} resolved to ${resolvedCwd} (not a directory)`);
2648
+ finally {
2649
+ await fileHandle?.close().catch(() => undefined);
815
2650
  }
816
- return resolvedCwd;
817
- }
818
- function buildAgentFsRpcSubject(userId, agentId) {
819
- return `doer.agent.fs.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
820
2651
  }
821
- function buildAgentShellRpcSubject(userId, agentId) {
822
- return `doer.agent.shell.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
2652
+ async function listAgentSessions() {
2653
+ const sessionsRoot = getSessionsRootPath();
2654
+ let sessionsRootStat;
2655
+ try {
2656
+ sessionsRootStat = await stat(sessionsRoot);
2657
+ }
2658
+ catch {
2659
+ return [];
2660
+ }
2661
+ if (!sessionsRootStat.isDirectory()) {
2662
+ return [];
2663
+ }
2664
+ const files = await collectSessionJsonlFiles(sessionsRoot);
2665
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
2666
+ const sessions = await Promise.all(files.slice(0, 10).map((file) => readSessionSummary(file.filePath, file.mtimeMs)));
2667
+ return sessions.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt) || b.filePath.localeCompare(a.filePath));
823
2668
  }
824
- function normalizeFsRpcPath(rawPath) {
825
- const root = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
826
- const raw = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
827
- const normalizedRaw = raw.replace(/\\/g, "/");
828
- const useAbsolute = path.isAbsolute(normalizedRaw);
829
- const rel = normalizedRaw.replace(/^\/+/, "") || ".";
830
- const abs = useAbsolute ? path.resolve(normalizedRaw) : path.resolve(root, rel);
831
- if (!useAbsolute && abs !== root && !abs.startsWith(root + path.sep)) {
832
- throw new Error("path escapes workspace root");
2669
+ async function readSessionLineIndex(filePath) {
2670
+ const resolvedFile = resolveSessionFilePath(filePath);
2671
+ const entryStat = await stat(resolvedFile);
2672
+ const nextSize = entryStat.size;
2673
+ const cached = sessionLineIndexCache.get(resolvedFile) ?? null;
2674
+ if (cached && cached.size === nextSize) {
2675
+ return cached;
833
2676
  }
834
- const formatPath = (target) => {
835
- if (useAbsolute) {
836
- return target.split(path.sep).join("/") || "/";
2677
+ const fileHandle = await open(resolvedFile, "r");
2678
+ try {
2679
+ let lineStartOffsets = cached?.lineStartOffsets.slice() ?? [];
2680
+ let scanStart = cached?.size ?? 0;
2681
+ let endsWithNewline = cached?.endsWithNewline ?? false;
2682
+ if (!cached || nextSize < cached.size) {
2683
+ lineStartOffsets = nextSize > 0 ? [0] : [];
2684
+ scanStart = 0;
2685
+ endsWithNewline = false;
2686
+ }
2687
+ else if (cached.endsWithNewline && nextSize > cached.size) {
2688
+ lineStartOffsets.push(cached.size);
2689
+ }
2690
+ let position = scanStart;
2691
+ const chunkBytes = 65_536;
2692
+ while (position < nextSize) {
2693
+ const readSize = Math.min(chunkBytes, nextSize - position);
2694
+ const buffer = Buffer.alloc(readSize);
2695
+ const { bytesRead } = await fileHandle.read(buffer, 0, readSize, position);
2696
+ if (bytesRead <= 0) {
2697
+ break;
2698
+ }
2699
+ for (let index = 0; index < bytesRead; index += 1) {
2700
+ if (buffer[index] !== 0x0a) {
2701
+ continue;
2702
+ }
2703
+ const nextLineStart = position + index + 1;
2704
+ if (nextLineStart < nextSize) {
2705
+ lineStartOffsets.push(nextLineStart);
2706
+ }
2707
+ }
2708
+ position += bytesRead;
2709
+ }
2710
+ if (nextSize > 0) {
2711
+ const tail = Buffer.alloc(1);
2712
+ const { bytesRead } = await fileHandle.read(tail, 0, 1, nextSize - 1);
2713
+ endsWithNewline = bytesRead > 0 && tail[0] === 0x0a;
2714
+ }
2715
+ else {
2716
+ endsWithNewline = false;
2717
+ }
2718
+ const nextEntry = {
2719
+ size: nextSize,
2720
+ lineStartOffsets,
2721
+ endsWithNewline,
2722
+ };
2723
+ sessionLineIndexCache.set(resolvedFile, nextEntry);
2724
+ return nextEntry;
2725
+ }
2726
+ finally {
2727
+ await fileHandle.close().catch(() => undefined);
2728
+ }
2729
+ }
2730
+ async function getAgentSessionRawRows(args) {
2731
+ const resolvedFile = resolveSessionFilePath(args.filePath);
2732
+ const index = await readSessionLineIndex(resolvedFile);
2733
+ const totalLines = index.lineStartOffsets.length;
2734
+ const sinceLine = Math.max(0, Math.floor(args.sinceLine));
2735
+ const beforeRowId = args.beforeRowId && args.beforeRowId > 0 ? Math.floor(args.beforeRowId) : null;
2736
+ const maxRawRows = 200;
2737
+ const maxRawBytes = 120_000;
2738
+ if (totalLines === 0) {
2739
+ return {
2740
+ rawRows: [],
2741
+ nextCursor: 0,
2742
+ };
2743
+ }
2744
+ let startLineIndex = 0;
2745
+ let endLineIndex = totalLines;
2746
+ const getLineSpanBytes = (lineIndex) => {
2747
+ const start = index.lineStartOffsets[lineIndex] ?? index.size;
2748
+ const end = lineIndex + 1 < totalLines ? (index.lineStartOffsets[lineIndex + 1] ?? index.size) : index.size;
2749
+ return Math.max(0, end - start);
2750
+ };
2751
+ if (beforeRowId !== null) {
2752
+ endLineIndex = Math.max(0, Math.min(totalLines, beforeRowId - 1));
2753
+ startLineIndex = endLineIndex;
2754
+ let collectedRows = 0;
2755
+ let collectedBytes = 0;
2756
+ while (startLineIndex > 0 && collectedRows < maxRawRows) {
2757
+ const nextIndex = startLineIndex - 1;
2758
+ const nextBytes = getLineSpanBytes(nextIndex);
2759
+ if (collectedRows > 0 && collectedBytes + nextBytes > maxRawBytes) {
2760
+ break;
2761
+ }
2762
+ startLineIndex = nextIndex;
2763
+ collectedRows += 1;
2764
+ collectedBytes += nextBytes;
2765
+ }
2766
+ }
2767
+ else if (sinceLine > 0) {
2768
+ startLineIndex = Math.min(totalLines, sinceLine);
2769
+ endLineIndex = startLineIndex;
2770
+ let collectedRows = 0;
2771
+ let collectedBytes = 0;
2772
+ while (endLineIndex < totalLines && collectedRows < maxRawRows) {
2773
+ const nextBytes = getLineSpanBytes(endLineIndex);
2774
+ if (collectedRows > 0 && collectedBytes + nextBytes > maxRawBytes) {
2775
+ break;
2776
+ }
2777
+ endLineIndex += 1;
2778
+ collectedRows += 1;
2779
+ collectedBytes += nextBytes;
837
2780
  }
838
- return path.relative(root, target).split(path.sep).join("/") || ".";
839
- };
840
- return { abs, formatPath };
841
- }
842
- function parseFsRpcAction(value) {
843
- if (value === "list" || value === "stat" || value === "fetch_file" || value === "read_text") {
844
- return value;
845
2781
  }
846
- throw new Error("unsupported action");
847
- }
848
- function normalizeFsRpcNumber(value, fallback) {
849
- const n = Number(value);
850
- if (!Number.isFinite(n)) {
851
- return fallback;
2782
+ else {
2783
+ startLineIndex = totalLines;
2784
+ let collectedRows = 0;
2785
+ let collectedBytes = 0;
2786
+ while (startLineIndex > 0 && collectedRows < maxRawRows) {
2787
+ const nextIndex = startLineIndex - 1;
2788
+ const nextBytes = getLineSpanBytes(nextIndex);
2789
+ if (collectedRows > 0 && collectedBytes + nextBytes > maxRawBytes) {
2790
+ break;
2791
+ }
2792
+ startLineIndex = nextIndex;
2793
+ collectedRows += 1;
2794
+ collectedBytes += nextBytes;
2795
+ }
852
2796
  }
853
- return Math.floor(n);
854
- }
855
- async function executeFsRpc(args) {
856
- const action = parseFsRpcAction(args.request.action);
857
- const { abs, formatPath } = normalizeFsRpcPath(args.request.path);
858
- if (action === "stat") {
859
- const entry = await stat(abs);
2797
+ if (startLineIndex >= endLineIndex) {
860
2798
  return {
861
- ok: true,
862
- action,
863
- path: formatPath(abs),
864
- kind: entry.isDirectory() ? "dir" : "file",
865
- size: entry.size,
866
- mtimeMs: entry.mtimeMs,
2799
+ rawRows: [],
2800
+ nextCursor: endLineIndex,
867
2801
  };
868
2802
  }
869
- if (action === "list") {
870
- const entry = await stat(abs);
871
- if (!entry.isDirectory()) {
872
- throw new Error("path is not a directory");
873
- }
874
- const limit = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.limit, 200), 1000));
875
- const rows = await readdir(abs, { withFileTypes: true });
876
- const items = await Promise.all(rows.map(async (row) => {
877
- const child = path.join(abs, row.name);
878
- const childStat = await stat(child);
879
- return {
880
- name: row.name,
881
- path: formatPath(child),
882
- kind: row.isDirectory() ? "dir" : "file",
883
- size: childStat.size,
884
- mtimeMs: childStat.mtimeMs,
885
- };
886
- }));
887
- items.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1));
2803
+ const startOffset = index.lineStartOffsets[startLineIndex] ?? index.size;
2804
+ const endOffset = endLineIndex < totalLines ? (index.lineStartOffsets[endLineIndex] ?? index.size) : index.size;
2805
+ if (startOffset >= endOffset) {
888
2806
  return {
889
- ok: true,
890
- action,
891
- path: formatPath(abs),
892
- items: items.slice(0, limit),
893
- truncated: items.length > limit,
894
- total: items.length,
2807
+ rawRows: [],
2808
+ nextCursor: endLineIndex,
895
2809
  };
896
2810
  }
897
- if (action === "fetch_file") {
898
- const entry = await stat(abs);
899
- if (!entry.isFile()) {
900
- throw new Error("path is not a file");
901
- }
902
- const uploadUrl = typeof args.request.uploadUrl === "string" ? args.request.uploadUrl : "";
903
- const chatId = typeof args.request.chatId === "string" ? args.request.chatId : "";
904
- const agentId = typeof args.request.agentId === "string" ? args.request.agentId : "";
905
- if (!uploadUrl || !chatId || !agentId) {
906
- throw new Error("missing upload parameters");
907
- }
908
- const data = await readFile(abs);
909
- const fileName = path.basename(abs) || "file";
910
- const form = new FormData();
911
- form.append("file", new File([data], fileName));
912
- form.append("chatId", chatId);
913
- form.append("agentId", agentId);
914
- const response = await fetch(uploadUrl, {
915
- method: "POST",
916
- headers: { Authorization: `Bearer ${args.agentToken}` },
917
- body: form,
918
- });
919
- const text = await response.text();
920
- let upload = {};
921
- try {
922
- upload = JSON.parse(text || "{}");
923
- }
924
- catch {
925
- upload = {};
2811
+ const fileHandle = await open(resolvedFile, "r");
2812
+ try {
2813
+ const readSize = endOffset - startOffset;
2814
+ const buffer = Buffer.alloc(readSize);
2815
+ const { bytesRead } = await fileHandle.read(buffer, 0, readSize, startOffset);
2816
+ const raw = buffer.toString("utf8", 0, bytesRead);
2817
+ const lines = raw.split(/\r?\n/);
2818
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
2819
+ lines.pop();
926
2820
  }
927
- if (!response.ok) {
928
- const message = typeof upload.error === "string" ? upload.error : `upload failed: ${response.status}`;
929
- throw new Error(message);
2821
+ const rawRows = [];
2822
+ let lineNumber = startLineIndex + 1;
2823
+ for (const line of lines) {
2824
+ if (line.trim()) {
2825
+ rawRows.push({
2826
+ id: lineNumber,
2827
+ raw: line,
2828
+ });
2829
+ }
2830
+ lineNumber += 1;
930
2831
  }
931
2832
  return {
932
- ok: true,
933
- action,
934
- path: formatPath(abs),
935
- size: entry.size,
936
- upload,
2833
+ rawRows,
2834
+ nextCursor: endLineIndex,
937
2835
  };
938
2836
  }
939
- const entry = await stat(abs);
940
- if (!entry.isFile()) {
941
- throw new Error("path is not a file");
2837
+ finally {
2838
+ await fileHandle.close().catch(() => undefined);
942
2839
  }
943
- const offset = Math.max(0, normalizeFsRpcNumber(args.request.offset, 0));
944
- const length = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.length, 65536), 262144));
945
- const encoding = typeof args.request.encoding === "string" && args.request.encoding ? args.request.encoding : "utf8";
946
- const fd = await open(abs, "r");
947
- try {
948
- const buffer = Buffer.alloc(length);
949
- const readResult = await fd.read(buffer, 0, length, offset);
950
- const slice = buffer.subarray(0, readResult.bytesRead);
2840
+ }
2841
+ function resolveSessionUploadsDir(sessionId) {
2842
+ const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
2843
+ const safeSessionId = sessionId.trim().replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "session";
2844
+ return path.join(workspaceRoot, ".doer-agent", "sessions", safeSessionId);
2845
+ }
2846
+ async function deleteAgentSession(filePath, sessionId) {
2847
+ const resolvedFile = resolveSessionFilePath(filePath);
2848
+ sessionLineIndexCache.delete(resolvedFile);
2849
+ await unlink(resolvedFile);
2850
+ if (sessionId) {
2851
+ await rm(resolveSessionUploadsDir(sessionId), { recursive: true, force: true }).catch(() => undefined);
2852
+ }
2853
+ const sessionsRoot = path.resolve(getSessionsRootPath());
2854
+ let currentDir = path.dirname(resolvedFile);
2855
+ while (currentDir.startsWith(sessionsRoot + path.sep)) {
951
2856
  try {
952
- const text = slice.toString(encoding);
953
- return {
2857
+ const entries = await readdir(currentDir);
2858
+ if (entries.length > 0) {
2859
+ break;
2860
+ }
2861
+ await rmdir(currentDir);
2862
+ }
2863
+ catch {
2864
+ break;
2865
+ }
2866
+ currentDir = path.dirname(currentDir);
2867
+ }
2868
+ }
2869
+ function publishSessionRpcResponse(args) {
2870
+ args.nc.publish(args.responseSubject, sessionRpcCodec.encode(JSON.stringify(args.payload)));
2871
+ }
2872
+ async function startSessionWatch(args) {
2873
+ const resolvedFile = resolveSessionFilePath(args.filePath);
2874
+ const watchId = `watch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
2875
+ let watcher = null;
2876
+ let active = true;
2877
+ const emitEvent = (event) => {
2878
+ if (!active) {
2879
+ return;
2880
+ }
2881
+ publishSessionRpcResponse({
2882
+ nc: args.nc,
2883
+ responseSubject: args.responseSubject,
2884
+ payload: {
2885
+ requestId: args.requestId,
954
2886
  ok: true,
955
- action,
956
- path: formatPath(abs),
957
- offset,
958
- length: readResult.bytesRead,
959
- totalSize: entry.size,
960
- eof: offset + readResult.bytesRead >= entry.size,
961
- encoding,
962
- text,
963
- bytesRead: readResult.bytesRead,
964
- };
2887
+ action: "watch",
2888
+ watchId,
2889
+ event,
2890
+ },
2891
+ });
2892
+ };
2893
+ const cleanup = () => {
2894
+ if (!active) {
2895
+ return;
965
2896
  }
966
- catch (error) {
967
- const message = error instanceof Error ? error.message : "failed to decode text";
968
- return {
969
- ok: false,
970
- action,
971
- path: formatPath(abs),
972
- error: message,
973
- };
2897
+ active = false;
2898
+ watcher?.close();
2899
+ watcher = null;
2900
+ activeSessionWatchers.delete(watchId);
2901
+ };
2902
+ const notifyFromContent = () => {
2903
+ emitEvent({
2904
+ type: "messages.changed",
2905
+ at: formatLocalTimestamp(),
2906
+ });
2907
+ };
2908
+ watcher = watch(resolvedFile, () => {
2909
+ notifyFromContent();
2910
+ });
2911
+ activeSessionWatchers.set(watchId, cleanup);
2912
+ emitEvent({ type: "stream.started", watchId, at: formatLocalTimestamp() });
2913
+ return watchId;
2914
+ }
2915
+ async function handleSessionRpcMessage(args) {
2916
+ let requestId = "unknown";
2917
+ let responseSubject = "";
2918
+ try {
2919
+ const payload = JSON.parse(sessionRpcCodec.decode(args.msg.data));
2920
+ const request = normalizeSessionRpcRequest({ request: payload, agentId: args.agentId });
2921
+ requestId = request.requestId;
2922
+ responseSubject = request.responseSubject;
2923
+ if (request.action === "list") {
2924
+ const sessions = await listAgentSessions();
2925
+ publishSessionRpcResponse({
2926
+ nc: args.jetstream.nc,
2927
+ responseSubject,
2928
+ payload: { requestId, ok: true, action: "list", sessions },
2929
+ });
2930
+ return;
2931
+ }
2932
+ if (request.action === "messages") {
2933
+ const result = await getAgentSessionRawRows({
2934
+ filePath: request.filePath ?? "",
2935
+ sinceLine: request.sinceLine,
2936
+ beforeRowId: request.beforeRowId,
2937
+ pageSize: request.pageSize,
2938
+ });
2939
+ publishSessionRpcResponse({
2940
+ nc: args.jetstream.nc,
2941
+ responseSubject,
2942
+ payload: { requestId, ok: true, action: "messages", rawRows: result.rawRows, nextCursor: result.nextCursor },
2943
+ });
2944
+ return;
2945
+ }
2946
+ if (request.action === "delete") {
2947
+ await deleteAgentSession(request.filePath ?? "", request.sessionId);
2948
+ publishSessionRpcResponse({
2949
+ nc: args.jetstream.nc,
2950
+ responseSubject,
2951
+ payload: { requestId, ok: true, action: "delete" },
2952
+ });
2953
+ return;
2954
+ }
2955
+ if (request.action === "watch") {
2956
+ const watchId = await startSessionWatch({
2957
+ nc: args.jetstream.nc,
2958
+ requestId,
2959
+ responseSubject,
2960
+ filePath: request.filePath ?? "",
2961
+ });
2962
+ publishSessionRpcResponse({
2963
+ nc: args.jetstream.nc,
2964
+ responseSubject,
2965
+ payload: { requestId, ok: true, action: "watch", watchId },
2966
+ });
2967
+ return;
974
2968
  }
2969
+ const stop = request.watchId ? activeSessionWatchers.get(request.watchId) : null;
2970
+ stop?.();
2971
+ publishSessionRpcResponse({
2972
+ nc: args.jetstream.nc,
2973
+ responseSubject,
2974
+ payload: { requestId, ok: true, action: "stop_watch", watchId: request.watchId },
2975
+ });
975
2976
  }
976
- finally {
977
- await fd.close();
2977
+ catch (error) {
2978
+ const message = error instanceof Error ? error.message : String(error);
2979
+ if (responseSubject) {
2980
+ publishSessionRpcResponse({
2981
+ nc: args.jetstream.nc,
2982
+ responseSubject,
2983
+ payload: {
2984
+ requestId,
2985
+ ok: false,
2986
+ error: message,
2987
+ },
2988
+ });
2989
+ }
2990
+ writeAgentError(`session rpc failed requestId=${requestId} error=${message}`);
978
2991
  }
979
2992
  }
2993
+ function subscribeToSessionRpc(args) {
2994
+ const subject = buildAgentSessionRpcSubject(args.userId, args.agentId);
2995
+ args.jetstream.nc.subscribe(subject, {
2996
+ callback: (error, msg) => {
2997
+ if (error) {
2998
+ const message = error instanceof Error ? error.message : String(error);
2999
+ writeAgentError(`session rpc subscription error: ${message}`);
3000
+ return;
3001
+ }
3002
+ void handleSessionRpcMessage({
3003
+ msg,
3004
+ jetstream: args.jetstream,
3005
+ agentId: args.agentId,
3006
+ });
3007
+ },
3008
+ });
3009
+ writeAgentInfo(`session rpc subscribed subject=${subject}`);
3010
+ }
980
3011
  async function handleFsRpcMessage(args) {
981
3012
  let payload = {};
982
3013
  try {
@@ -984,7 +3015,11 @@ async function handleFsRpcMessage(args) {
984
3015
  if (typeof payload.agentId === "string" && payload.agentId.trim() && payload.agentId !== args.agentId) {
985
3016
  throw new Error("agent id mismatch");
986
3017
  }
987
- const result = await executeFsRpc({ request: payload, agentToken: args.agentToken });
3018
+ const result = await executeFsRpc({
3019
+ request: payload,
3020
+ serverBaseUrl: args.serverBaseUrl,
3021
+ agentToken: args.agentToken,
3022
+ });
988
3023
  args.msg.respond(fsRpcCodec.encode(JSON.stringify(result)));
989
3024
  }
990
3025
  catch (error) {
@@ -1094,7 +3129,8 @@ async function handleShellRpcMessage(args) {
1094
3129
  const startedAtMs = Date.now();
1095
3130
  const prepared = await prepareCommandExecution({
1096
3131
  cwd: request.cwd,
1097
- runtimeEnvPatch: request.runtimeEnvPatch,
3132
+ userId: args.userId,
3133
+ taskId: request.requestId,
1098
3134
  codexAuthBundle: request.codexAuthBundle,
1099
3135
  });
1100
3136
  const child = spawnPreparedCommand({
@@ -1181,6 +3217,7 @@ function subscribeToShellRpc(args) {
1181
3217
  void handleShellRpcMessage({
1182
3218
  msg,
1183
3219
  jetstream: args.jetstream,
3220
+ userId: args.userId,
1184
3221
  agentId: args.agentId,
1185
3222
  agentToken: args.agentToken,
1186
3223
  });
@@ -1351,49 +3388,39 @@ async function checkCancelRequested(args) {
1351
3388
  return Boolean(response.task?.cancelRequested);
1352
3389
  }
1353
3390
  async function prepareTaskCodexAuth(args) {
1354
- const bundle = await postJson(`${args.serverBaseUrl}/api/agent/tasks/${encodeURIComponent(args.taskId)}/codex-auth`, {
1355
- userId: args.userId,
1356
- agentToken: args.agentToken,
1357
- }).catch((error) => {
1358
- const message = error instanceof Error ? error.message : String(error);
1359
- writeAgentError(`task=${args.taskId} codex auth sync skipped: ${message}`);
1360
- return null;
1361
- });
1362
- return await prepareCodexAuthBundle(bundle);
3391
+ void args;
3392
+ return {
3393
+ envPatch: {},
3394
+ cleanup: async () => { },
3395
+ meta: {
3396
+ codexAuthSource: "agent_local",
3397
+ codexAuthSynced: false,
3398
+ },
3399
+ };
1363
3400
  }
1364
3401
  async function prepareCodexAuthBundle(bundle) {
1365
- if (!bundle || typeof bundle.authJson !== "string") {
1366
- return null;
1367
- }
1368
- const codexHome = resolveCodexHomePath();
1369
- await mkdir(codexHome, { recursive: true });
1370
- const authFile = path.join(codexHome, "auth.json");
1371
- await writeFile(authFile, bundle.authJson, "utf8");
1372
- await chmod(authFile, 0o600).catch(() => undefined);
1373
- const envPatch = {
1374
- CODEX_HOME: codexHome,
1375
- };
1376
- if (typeof bundle.apiKey === "string" && bundle.apiKey.trim()) {
1377
- envPatch.OPENAI_API_KEY = bundle.apiKey.trim();
1378
- }
1379
- const cleanup = async () => { };
3402
+ void bundle;
1380
3403
  return {
1381
- envPatch,
1382
- cleanup,
3404
+ envPatch: {},
3405
+ cleanup: async () => { },
1383
3406
  meta: {
1384
- codexAuthMode: bundle.authMode ?? null,
1385
- codexAuthIssuedAt: bundle.issuedAt ?? null,
1386
- codexAuthExpiresAt: bundle.expiresAt ?? null,
1387
- codexAuthSynced: true,
3407
+ codexAuthSource: "agent_local",
3408
+ codexAuthSynced: false,
1388
3409
  },
1389
3410
  };
1390
3411
  }
1391
3412
  async function prepareCommandExecution(args) {
1392
3413
  const shellPath = resolveShellPath();
1393
3414
  const taskWorkspace = resolveTaskWorkspace(args.cwd);
3415
+ const codexHome = resolveCodexHomePath();
3416
+ await mkdir(codexHome, { recursive: true });
1394
3417
  const codexAuth = await prepareCodexAuthBundle(args.codexAuthBundle);
3418
+ const localAgentSettings = await readAgentSettingsConfig(null);
1395
3419
  const baseTaskEnvPatch = {
1396
- ...args.runtimeEnvPatch,
3420
+ CODEX_HOME: codexHome,
3421
+ DOER_USER_ID: args.userId,
3422
+ DOER_AGENT_TASK_ID: args.taskId,
3423
+ ...buildAgentSettingsEnvPatch(localAgentSettings),
1397
3424
  ...(codexAuth?.envPatch ?? {}),
1398
3425
  WORKSPACE: taskWorkspace,
1399
3426
  };
@@ -1484,6 +3511,8 @@ async function runTask(args) {
1484
3511
  };
1485
3512
  const shellPath = resolveShellPath();
1486
3513
  const taskWorkspace = resolveTaskWorkspace(args.cwd);
3514
+ const codexHome = resolveCodexHomePath();
3515
+ await mkdir(codexHome, { recursive: true });
1487
3516
  const runtimeConfig = await prepareTaskRuntimeConfig({
1488
3517
  serverBaseUrl: args.serverBaseUrl,
1489
3518
  taskId: args.taskId,
@@ -1496,7 +3525,10 @@ async function runTask(args) {
1496
3525
  userId: args.userId,
1497
3526
  agentToken: args.agentToken,
1498
3527
  });
3528
+ const localAgentSettings = await readAgentSettingsConfig(null);
1499
3529
  const baseTaskEnvPatch = {
3530
+ CODEX_HOME: codexHome,
3531
+ ...buildAgentSettingsEnvPatch(localAgentSettings),
1500
3532
  ...(runtimeConfig?.envPatch ?? {}),
1501
3533
  ...(codexAuth?.envPatch ?? {}),
1502
3534
  WORKSPACE: taskWorkspace,
@@ -1699,6 +3731,10 @@ async function main() {
1699
3731
  const startupWorkspaceRoot = path.resolve(workspaceDir || process.cwd());
1700
3732
  workspaceRootOverride = startupWorkspaceRoot;
1701
3733
  process.chdir(startupWorkspaceRoot);
3734
+ process.env.WORKSPACE = startupWorkspaceRoot;
3735
+ process.env.CODEX_HOME = path.join(startupWorkspaceRoot, ".codex");
3736
+ await mkdir(process.env.CODEX_HOME, { recursive: true }).catch(() => undefined);
3737
+ await resetRunsDir();
1702
3738
  const serverBaseUrlRaw = resolveArgOrEnv(args, ["server", "url"], ["DOER_AGENT_SERVER"], DEFAULT_SERVER_BASE_URL);
1703
3739
  const requestedServerBaseUrl = serverBaseUrlRaw.replace(/\/$/, "");
1704
3740
  const serverBaseUrl = resolveContainerReachableServerBaseUrl(requestedServerBaseUrl);
@@ -1764,8 +3800,36 @@ async function main() {
1764
3800
  agentId: initialAgentId,
1765
3801
  agentToken,
1766
3802
  });
3803
+ subscribeToSessionRpc({
3804
+ jetstream,
3805
+ userId,
3806
+ agentId: initialAgentId,
3807
+ });
3808
+ subscribeToCodexRpc({
3809
+ jetstream,
3810
+ serverBaseUrl,
3811
+ userId,
3812
+ agentId: initialAgentId,
3813
+ agentToken,
3814
+ });
3815
+ subscribeToCodexAuthRpc({
3816
+ jetstream,
3817
+ userId,
3818
+ agentId: initialAgentId,
3819
+ });
3820
+ subscribeToSettingsRpc({
3821
+ jetstream,
3822
+ userId,
3823
+ agentId: initialAgentId,
3824
+ });
3825
+ subscribeToGitRpc({
3826
+ jetstream,
3827
+ userId,
3828
+ agentId: initialAgentId,
3829
+ });
1767
3830
  subscribeToRunRpc({
1768
3831
  jetstream,
3832
+ serverBaseUrl,
1769
3833
  userId,
1770
3834
  agentId: initialAgentId,
1771
3835
  agentToken,