@phnx-labs/agents-cli 1.20.29 → 1.20.30

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 (45) hide show
  1. package/dist/commands/computer-actions.js +6 -2
  2. package/dist/commands/computer.d.ts +12 -0
  3. package/dist/commands/computer.js +88 -13
  4. package/dist/commands/inspect.js +1 -1
  5. package/dist/commands/models.js +8 -2
  6. package/dist/commands/sessions.js +156 -44
  7. package/dist/commands/sync.js +70 -14
  8. package/dist/lib/agents.d.ts +0 -4
  9. package/dist/lib/agents.js +54 -5
  10. package/dist/lib/browser/drivers/ssh.js +4 -35
  11. package/dist/lib/computer-rpc.d.ts +6 -1
  12. package/dist/lib/computer-rpc.js +86 -3
  13. package/dist/lib/exec.js +14 -0
  14. package/dist/lib/models.js +138 -5
  15. package/dist/lib/runner.js +7 -7
  16. package/dist/lib/session/active.d.ts +13 -0
  17. package/dist/lib/session/active.js +79 -18
  18. package/dist/lib/session/cloud.js +2 -0
  19. package/dist/lib/session/db.d.ts +11 -0
  20. package/dist/lib/session/db.js +62 -5
  21. package/dist/lib/session/discover.d.ts +5 -0
  22. package/dist/lib/session/discover.js +81 -0
  23. package/dist/lib/session/parse.d.ts +15 -0
  24. package/dist/lib/session/parse.js +22 -2
  25. package/dist/lib/session/remote.d.ts +1 -1
  26. package/dist/lib/session/remote.js +8 -3
  27. package/dist/lib/session/state.d.ts +82 -0
  28. package/dist/lib/session/state.js +221 -0
  29. package/dist/lib/session/tail.d.ts +18 -0
  30. package/dist/lib/session/tail.js +57 -0
  31. package/dist/lib/session/types.d.ts +9 -0
  32. package/dist/lib/session/width.d.ts +29 -0
  33. package/dist/lib/session/width.js +91 -0
  34. package/dist/lib/shims.d.ts +17 -1
  35. package/dist/lib/shims.js +130 -6
  36. package/dist/lib/ssh-tunnel.d.ts +127 -0
  37. package/dist/lib/ssh-tunnel.js +346 -0
  38. package/dist/lib/state.d.ts +2 -0
  39. package/dist/lib/state.js +17 -1
  40. package/dist/lib/teams/agents.d.ts +11 -1
  41. package/dist/lib/teams/agents.js +16 -2
  42. package/dist/lib/types.d.ts +1 -0
  43. package/dist/lib/versions.d.ts +19 -0
  44. package/dist/lib/versions.js +84 -24
  45. package/package.json +1 -1
@@ -30,7 +30,7 @@
30
30
  import * as path from 'path';
31
31
  import chalk from 'chalk';
32
32
  import { agentLabel, resolveAgentName } from '../lib/agents.js';
33
- import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, } from '../lib/versions.js';
33
+ import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildRepoScopedSelection, listRepoNames, } from '../lib/versions.js';
34
34
  import { compileRulesForProject } from '../lib/rules/compile.js';
35
35
  import { runLaunchSync } from '../lib/project-launch.js';
36
36
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -38,11 +38,12 @@ import { runUmbrellaSync } from '../lib/sync-umbrella.js';
38
38
  /** Register the `agents sync` command. */
39
39
  export function registerSyncCommand(program) {
40
40
  program
41
- .command('sync [agentSpec]')
41
+ .command('sync [agentSpec] [repo]')
42
42
  .summary('Make this machine current, or sync resources into one agent')
43
- .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", or a selector: @latest / @oldest / @pinned (= @default).\n\nWith NO agent, runs the umbrella verb: fetch remote state (config repos + secrets + sessions) then reconcile it into every installed agent. Scope it with --repos / --secrets / --sessions, --cloud (fetch only), or --local (reconcile only).')
43
+ .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", a selector: @latest / @oldest / @pinned (= @default), or @all for every installed version.\n\nAppend a [repo] (or pass --repo) to scope the sync to a single DotAgent repo — system / user / project / <alias>. e.g. "agents sync claude@all system" reconciles only the system repo\'s resources into every installed Claude.\n\nWith NO agent, runs the umbrella verb: fetch remote state (config repos + secrets + sessions) then reconcile it into every installed agent. Scope it with --repos / --secrets / --sessions, --cloud (fetch only), or --local (reconcile only).')
44
44
  .option('--agent <agent>', 'Agent identifier (legacy form; prefer the positional spec)')
45
45
  .option('--agent-version <version>', 'Version to sync into (legacy form; prefer "agent@version")')
46
+ .option('--repo <name>', 'Scope the sync to a single DotAgent repo: system / user / project / <alias> (also accepted as a positional)')
46
47
  .option('--project-dir <path>', 'Path to project-level .agents/ directory containing project-scoped resources')
47
48
  .option('--cwd <path>', 'Working directory for discovering project manifest and resources')
48
49
  .option('--launch', 'Hot-path mode (shim only): skip version-home reconciliation, run project-scoped compile + workspace mirror + plugin marketplaces', false)
@@ -55,8 +56,8 @@ export function registerSyncCommand(program) {
55
56
  .option('--sessions', 'Umbrella: sync session transcripts across machines', false)
56
57
  .option('--cloud', 'Umbrella: fetch all remote state but skip the local reconcile', false)
57
58
  .option('--local', "Umbrella: reconcile resources into installed agents only (no fetch)", false)
58
- .action(async (agentSpec, opts) => {
59
- await runSync(agentSpec, opts);
59
+ .action(async (agentSpec, repo, opts) => {
60
+ await runSync(agentSpec, repo, opts);
60
61
  });
61
62
  }
62
63
  /**
@@ -109,7 +110,7 @@ async function runUmbrella(opts, quiet, outLog, errLog) {
109
110
  process.exitCode = 1;
110
111
  }
111
112
  }
112
- async function runSync(agentSpec, opts) {
113
+ async function runSync(agentSpec, repoArg, opts) {
113
114
  const quiet = !!opts.quiet;
114
115
  const errLog = (msg) => { if (!quiet)
115
116
  console.error(msg); };
@@ -119,16 +120,17 @@ async function runSync(agentSpec, opts) {
119
120
  let agentId;
120
121
  let version;
121
122
  // A positional @selector typed by the user (latest/oldest/pinned/default/
122
- // explicit). parseAgentSpec defaults a missing version to 'latest', so a bare
123
- // `agents sync claude` and `agents sync claude@latest` are indistinguishable
124
- // after parsing — we only treat the version as a selector when an '@' was
125
- // actually typed, keeping bare `claude` on the default-version path.
123
+ // all/explicit). parseAgentSpec defaults a missing version to 'latest', so a
124
+ // bare `agents sync claude` and `agents sync claude@latest` are
125
+ // indistinguishable after parsing — we only treat the version as a selector
126
+ // when an '@' was actually typed, keeping bare `claude` on the
127
+ // default-version path.
126
128
  let selector;
127
129
  if (agentSpec) {
128
130
  const parsed = parseAgentSpec(agentSpec);
129
131
  if (!parsed) {
130
132
  errLog(chalk.red(`Invalid agent spec '${agentSpec}'.`));
131
- errLog(chalk.gray('Examples: claude, claude@2.1.142, claude@latest, claude@oldest, claude@pinned'));
133
+ errLog(chalk.gray('Examples: claude, claude@2.1.142, claude@latest, claude@oldest, claude@pinned, claude@all'));
132
134
  process.exitCode = 1;
133
135
  return;
134
136
  }
@@ -136,6 +138,18 @@ async function runSync(agentSpec, opts) {
136
138
  if (agentSpec.includes('@'))
137
139
  selector = parsed.version;
138
140
  }
141
+ // Repo scope: --repo flag wins over the positional. Validate against the
142
+ // known DotAgent repos so a typo fails loudly instead of syncing nothing.
143
+ const repoScope = opts.repo || repoArg;
144
+ if (repoScope !== undefined) {
145
+ const known = listRepoNames();
146
+ if (!known.includes(repoScope)) {
147
+ errLog(chalk.red(`Unknown repo '${repoScope}'.`));
148
+ errLog(chalk.gray(`Known repos: ${known.join(', ')}`));
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ }
139
153
  if (opts.agent) {
140
154
  const resolved = resolveAgentName(opts.agent);
141
155
  if (!resolved) {
@@ -156,6 +170,37 @@ async function runSync(agentSpec, opts) {
156
170
  await runUmbrella(opts, quiet, outLog, errLog);
157
171
  return;
158
172
  }
173
+ const projectDir = opts.projectDir;
174
+ const cwd = opts.cwd || process.cwd();
175
+ const force = !!opts.force;
176
+ // ---------- 2a. @all: reconcile every installed version of this agent ----------
177
+ // Non-interactive by design — fanning an interactive preview across N
178
+ // versions is unusable. Honors an optional repo scope.
179
+ if (selector === 'all') {
180
+ const installed = listInstalledVersions(agentId);
181
+ if (installed.length === 0) {
182
+ errLog(chalk.red(`No ${agentLabel(agentId)} versions installed.`));
183
+ errLog(chalk.gray(`Install one: agents add ${agentId}@latest`));
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+ let selection;
188
+ if (repoScope) {
189
+ selection = buildRepoScopedSelection(repoScope, cwd);
190
+ if (Object.keys(selection).length === 0) {
191
+ outLog(chalk.gray(`Nothing from repo '${repoScope}' to sync.`));
192
+ return;
193
+ }
194
+ }
195
+ const scopeLabel = repoScope ? chalk.gray(` (repo: ${repoScope})`) : '';
196
+ outLog(chalk.cyan(`Syncing ${installed.length} ${agentLabel(agentId)} version(s)${scopeLabel}.`));
197
+ for (const v of installed) {
198
+ const result = syncResourcesToVersion(agentId, v, selection, { projectDir, cwd, force });
199
+ if (!quiet)
200
+ printSyncDetail(result, agentId, v, cwd);
201
+ }
202
+ return;
203
+ }
159
204
  // ---------- 2. Resolve version (project pin → global default → sole installed) ----------
160
205
  // A positional @selector wins over the default-resolution below.
161
206
  // @latest / @oldest → newest / oldest installed (process.exit if none)
@@ -197,15 +242,26 @@ async function runSync(agentSpec, opts) {
197
242
  process.exitCode = 1;
198
243
  return;
199
244
  }
200
- const projectDir = opts.projectDir;
201
- const cwd = opts.cwd || process.cwd();
202
245
  // ---------- 3. --launch mode bypasses everything below ----------
203
246
  if (opts.launch) {
204
247
  runLaunchMode(agentId, version, cwd, quiet);
205
248
  return;
206
249
  }
250
+ // ---------- 3b. Repo-scoped single-version sync ----------
251
+ // An explicit --repo / positional repo is a targeted request, so skip the
252
+ // interactive preview and reconcile just that repo's resources.
253
+ if (repoScope) {
254
+ const scoped = buildRepoScopedSelection(repoScope, cwd);
255
+ if (Object.keys(scoped).length === 0) {
256
+ outLog(chalk.gray(`Nothing from repo '${repoScope}' to sync into ${agentLabel(agentId)}@${version}.`));
257
+ return;
258
+ }
259
+ const result = syncResourcesToVersion(agentId, version, scoped, { projectDir, cwd, force });
260
+ if (!quiet)
261
+ printSyncDetail(result, agentId, version, cwd);
262
+ return;
263
+ }
207
264
  // ---------- 4. Decide selection (interactive preview vs auto) ----------
208
- const force = !!opts.force;
209
265
  const yes = !!opts.yes;
210
266
  const interactive = !quiet && !yes && isInteractiveTerminal();
211
267
  let selection;
@@ -111,10 +111,6 @@ export interface AccountInfo {
111
111
  }
112
112
  /** Return the email address associated with the agent's auth config, or null. */
113
113
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
114
- /**
115
- * Extract full account information (identity, plan, usage status, credits) from
116
- * the agent's local auth/config files. Supports Claude, Codex, and Gemini.
117
- */
118
114
  export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
119
115
  /**
120
116
  * Determine when the agent was last used by checking session file mtimes,
@@ -410,6 +410,7 @@ export const AGENTS = {
410
410
  npmPackage: '',
411
411
  installScript: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
412
412
  configDir: path.join(HOME, '.gemini', 'antigravity-cli'),
413
+ authFiles: ['antigravity-oauth-token'],
413
414
  commandsDir: path.join(HOME, '.gemini', 'antigravity-cli', 'commands'),
414
415
  commandsSubdir: 'commands',
415
416
  skillsDir: path.join(HOME, '.gemini', 'antigravity-cli', 'skills'),
@@ -470,6 +471,7 @@ export const AGENTS = {
470
471
  npmPackage: '@moonshot-ai/kimi-code',
471
472
  installScript: 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash',
472
473
  configDir: path.join(HOME, '.kimi-code'),
474
+ authFiles: ['credentials/kimi-code.json'],
473
475
  commandsDir: '',
474
476
  commandsSubdir: '',
475
477
  skillsDir: path.join(HOME, '.kimi-code', 'skills'),
@@ -494,7 +496,8 @@ export const AGENTS = {
494
496
  },
495
497
  // Factory AI Droid CLI (`droid`) — agentic coding CLI from factory.ai.
496
498
  // Install: `curl -fsSL https://app.factory.ai/cli | sh` (no npm package).
497
- // Binary is NOT in node_modules/.bin — resolved via resolveDroidBinary().
499
+ // Binary is NOT in node_modules/.bin — the shim resolves the fixed install
500
+ // path ~/.local/bin/droid directly (see the droid branch in shims.ts).
498
501
  // Config: `~/.factory/` (settings.json, mcp.json, droids/, commands/).
499
502
  // Memory: native AGENTS.md. Subagents = custom droids (top-level .md files
500
503
  // in ~/.factory/droids/). Config isolation rides the ~/.factory symlink
@@ -508,6 +511,7 @@ export const AGENTS = {
508
511
  npmPackage: '',
509
512
  installScript: 'curl -fsSL https://app.factory.ai/cli | sh',
510
513
  configDir: path.join(HOME, '.factory'),
514
+ authFiles: ['auth.v2.file', 'auth.v2.key'],
511
515
  commandsDir: path.join(HOME, '.factory', 'commands'),
512
516
  commandsSubdir: 'commands',
513
517
  skillsDir: '', // no skills concept
@@ -707,6 +711,7 @@ export const UNMANAGED_DETECTION_CANDIDATES = [
707
711
  'gemini',
708
712
  'grok',
709
713
  'copilot',
714
+ 'droid',
710
715
  ];
711
716
  /**
712
717
  * Detect existing agent installations that are NOT yet managed by agents-cli.
@@ -770,6 +775,33 @@ export async function getAccountEmail(agentId, home) {
770
775
  * Extract full account information (identity, plan, usage status, credits) from
771
776
  * the agent's local auth/config files. Supports Claude, Codex, and Gemini.
772
777
  */
778
+ /**
779
+ * Resolve a file-auth agent's credential file. Sign-in is account-global, but
780
+ * each installed version gets an isolated home; the credential physically lives
781
+ * only in the home the user logged in under (the one the `~/.<config>` symlink
782
+ * targets). Check the per-version `base` first, then fall back to the active
783
+ * config location under the real HOME so every installed version reflects the
784
+ * true account state (droid/antigravity/kimi all stored login per-version-home
785
+ * and showed non-active versions as "not signed in"). Returns the first
786
+ * existing path, or null.
787
+ */
788
+ function resolveAccountCredentialPath(base, ...segments) {
789
+ const perVersion = path.join(base, ...segments);
790
+ try {
791
+ if (fs.existsSync(perVersion))
792
+ return perVersion;
793
+ }
794
+ catch { /* unreadable */ }
795
+ const active = path.join(process.env.AGENTS_REAL_HOME || os.homedir(), ...segments);
796
+ if (active !== perVersion) {
797
+ try {
798
+ if (fs.existsSync(active))
799
+ return active;
800
+ }
801
+ catch { /* unreadable */ }
802
+ }
803
+ return null;
804
+ }
773
805
  export async function getAccountInfo(agentId, home) {
774
806
  const base = home || os.homedir();
775
807
  const empty = {
@@ -924,8 +956,8 @@ export async function getAccountInfo(agentId, home) {
924
956
  // OAuth grant (access + refresh token, no id_token), so there's no email
925
957
  // claim to read locally — presence of a refresh token is the only
926
958
  // signed-in signal we can derive without a network call.
927
- const tokenPath = path.join(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
928
- if (!fs.existsSync(tokenPath))
959
+ const tokenPath = resolveAccountCredentialPath(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
960
+ if (!tokenPath)
929
961
  return { ...empty, lastActive };
930
962
  const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
931
963
  const hasToken = typeof data?.token?.refresh_token === 'string' && !!data.token.refresh_token;
@@ -938,8 +970,8 @@ export async function getAccountInfo(agentId, home) {
938
970
  // ~/.kimi-code/credentials/kimi-code.json. The access token is a JWT
939
971
  // whose payload carries an opaque user_id (no email), so we report
940
972
  // signed-in state plus a stable account key for usage dedup.
941
- const credPath = path.join(base, '.kimi-code', 'credentials', 'kimi-code.json');
942
- if (!fs.existsSync(credPath))
973
+ const credPath = resolveAccountCredentialPath(base, '.kimi-code', 'credentials', 'kimi-code.json');
974
+ if (!credPath)
943
975
  return { ...empty, lastActive };
944
976
  const data = JSON.parse(await fs.promises.readFile(credPath, 'utf-8'));
945
977
  const accessToken = data?.access_token;
@@ -950,6 +982,18 @@ export async function getAccountInfo(agentId, home) {
950
982
  const accountKey = buildIdentityKey(agentId, [['user', userId]]);
951
983
  return { ...empty, signedIn: true, accountId: userId, accountKey, lastActive };
952
984
  }
985
+ case 'droid': {
986
+ // Factory Droid stores auth at ~/.factory/auth.v2.file (+ auth.v2.key,
987
+ // an encrypted blob). No email/JWT is readable locally, so presence of
988
+ // the auth file is the only signed-in signal we can derive without a
989
+ // network call — same pattern as antigravity/kimi. `.factory` is the
990
+ // config dir on every platform (macOS/Linux ~/.factory, Windows
991
+ // %USERPROFILE%\.factory), so path.join keeps this cross-platform.
992
+ const authPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
993
+ if (!authPath)
994
+ return { ...empty, lastActive };
995
+ return { ...empty, signedIn: true, lastActive };
996
+ }
953
997
  default:
954
998
  return { ...empty, lastActive };
955
999
  }
@@ -1051,6 +1095,8 @@ function getSessionDir(agentId, base) {
1051
1095
  // Copilot persists sessions at ~/.copilot/session-state/<id>/events.jsonl.
1052
1096
  // The events.jsonl is the canonical NDJSON event stream per session.
1053
1097
  return path.join(base, '.copilot', 'session-state');
1098
+ case 'droid':
1099
+ return path.join(base, '.factory', 'sessions');
1054
1100
  default:
1055
1101
  return null;
1056
1102
  }
@@ -1061,6 +1107,7 @@ function getSessionExtension(agentId) {
1061
1107
  case 'claude':
1062
1108
  case 'codex':
1063
1109
  case 'copilot':
1110
+ case 'droid':
1064
1111
  return '.jsonl';
1065
1112
  case 'gemini':
1066
1113
  return '.json';
@@ -1661,6 +1708,8 @@ export const AGENT_NAME_ALIASES = {
1661
1708
  gk: 'grok',
1662
1709
  kimi: 'kimi',
1663
1710
  'kimi-code': 'kimi',
1711
+ factory: 'droid',
1712
+ 'factory-ai': 'droid',
1664
1713
  };
1665
1714
  /**
1666
1715
  * Resolve a user-provided agent name (alias, shorthand, or canonical) to its AgentId.
@@ -8,6 +8,10 @@ import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
8
8
  // so existing importers of `shellQuote` from this module keep working.
9
9
  import { shellQuote } from '../../ssh-exec.js';
10
10
  export { shellQuote };
11
+ // The `ssh -L` tunnel spawn is shared with `agents computer --host`; it lives in
12
+ // the single ssh-tunnel helper. Calling it with no options preserves this
13
+ // driver's original foreground, stderr-captured behavior exactly.
14
+ import { startSSHTunnel } from '../../ssh-tunnel.js';
11
15
  export async function connectSSH(endpoint, profile) {
12
16
  const url = new URL(endpoint);
13
17
  if (url.protocol !== 'ssh:') {
@@ -102,41 +106,6 @@ export async function connectSSH(endpoint, profile) {
102
106
  },
103
107
  };
104
108
  }
105
- function startSSHTunnel(user, host, localPort, remotePort) {
106
- return new Promise((resolve, reject) => {
107
- const args = [
108
- '-L',
109
- `${localPort}:127.0.0.1:${remotePort}`,
110
- `${user}@${host}`,
111
- '-N',
112
- '-o',
113
- 'StrictHostKeyChecking=accept-new',
114
- '-o',
115
- 'BatchMode=yes',
116
- '-o',
117
- 'ConnectTimeout=10',
118
- ];
119
- const tunnel = spawn('ssh', args, {
120
- stdio: ['ignore', 'ignore', 'pipe'],
121
- detached: false,
122
- });
123
- let stderr = '';
124
- tunnel.stderr?.on('data', (data) => {
125
- stderr += data.toString();
126
- });
127
- tunnel.on('error', (err) => {
128
- reject(new Error(`SSH tunnel failed: ${err.message}`));
129
- });
130
- setTimeout(() => {
131
- if (tunnel.killed) {
132
- reject(new Error(`SSH tunnel died: ${stderr}`));
133
- }
134
- else {
135
- resolve(tunnel);
136
- }
137
- }, 500);
138
- });
139
- }
140
109
  async function waitForPort(port, timeoutMs) {
141
110
  const start = Date.now();
142
111
  while (Date.now() - start < timeoutMs) {
@@ -20,10 +20,15 @@ export declare function loadDefaultPeers(): string[];
20
20
  export declare function writeComputerPeers(allowedExecPaths: string[]): void;
21
21
  export declare function resolveHelperExec(): string | null;
22
22
  export declare function resolveHelperApp(): string | null;
23
+ export declare function resolveTcpEndpoint(): {
24
+ host: string;
25
+ port: number;
26
+ token: string | null;
27
+ } | null;
23
28
  export declare function openComputerClient(): ComputerClient;
24
29
  export declare const RPC_TIMEOUT_MS = 30000;
25
30
  export declare function resolveRpcTimeoutMs(env: string | undefined): number;
26
31
  export declare function describeTransport(): {
27
- kind: 'socket' | 'stdio' | 'none';
32
+ kind: 'socket' | 'stdio' | 'tcp' | 'none';
28
33
  path: string | null;
29
34
  };
@@ -187,9 +187,31 @@ export function resolveHelperApp() {
187
187
  // exec = <bundle>/Contents/MacOS/ComputerHelper
188
188
  return path.resolve(exec, '..', '..', '..');
189
189
  }
190
- // Pick the best transport. If the socket exists, use it. Otherwise fall
191
- // back to spawning the helper as a subprocess (legacy path).
190
+ // Resolve the TCP endpoint for the Windows daemon (computer-helper-win), if
191
+ // configured. The Windows helper binds loopback TCP (Program.cs) and the CLI
192
+ // reaches it over an `ssh -L` tunnel, so the endpoint is a local forwarded
193
+ // port. COMPUTER_HELPER_TCP is "host:port" (host defaults to 127.0.0.1);
194
+ // COMPUTER_HELPER_TOKEN is the shared secret sent in the first `auth` frame.
195
+ export function resolveTcpEndpoint() {
196
+ const raw = process.env.COMPUTER_HELPER_TCP;
197
+ if (!raw || raw.length === 0)
198
+ return null;
199
+ const [hostPart, portPart] = raw.includes(':') ? raw.split(':') : ['127.0.0.1', raw];
200
+ const port = Number(portPart);
201
+ if (!Number.isInteger(port) || port <= 0)
202
+ return null;
203
+ const token = process.env.COMPUTER_HELPER_TOKEN;
204
+ return { host: hostPart || '127.0.0.1', port, token: token && token.length > 0 ? token : null };
205
+ }
206
+ // Pick the best transport. Precedence:
207
+ // 1. COMPUTER_HELPER_TCP -> the Windows daemon over a (tunneled) TCP port.
208
+ // 2. the macOS launchd socket if it exists.
209
+ // 3. spawning the helper as a subprocess (legacy/dev fallback).
192
210
  export function openComputerClient() {
211
+ const tcp = resolveTcpEndpoint();
212
+ if (tcp) {
213
+ return new TcpClient(tcp.host, tcp.port, tcp.token);
214
+ }
193
215
  const sockPath = resolveSocketPath();
194
216
  if (fs.existsSync(sockPath)) {
195
217
  return new SocketClient(sockPath);
@@ -297,6 +319,61 @@ class SocketClient extends BaseClient {
297
319
  });
298
320
  }
299
321
  }
322
+ // TCP transport for the Windows daemon (computer-helper-win). The daemon
323
+ // binds loopback only (Program.cs); the CLI reaches it over an `ssh -L`
324
+ // tunnel, so `host` is typically 127.0.0.1 + a forwarded port. When a token
325
+ // is configured the daemon accepts only an `auth` frame until authenticated,
326
+ // so we send that first and gate every other call on it.
327
+ class TcpClient extends BaseClient {
328
+ sock;
329
+ authReady;
330
+ constructor(host, port, token) {
331
+ super();
332
+ this.sock = createConnection({ host, port });
333
+ this.sock.setEncoding('utf8');
334
+ this.sock.on('data', (chunk) => this.handleChunk(chunk));
335
+ this.sock.on('error', (err) => {
336
+ this.closed = true;
337
+ this.failPending('socket_error', err.message);
338
+ });
339
+ this.sock.on('close', () => {
340
+ this.closed = true;
341
+ this.failPending('helper_exited', 'tcp connection closed before reply');
342
+ });
343
+ // Kick off the auth handshake synchronously so its frame (id 1) is the
344
+ // first thing written. No token → daemon is open (tunnel-gated).
345
+ this.authReady = token ? this.authenticate(token) : Promise.resolve();
346
+ }
347
+ async authenticate(token) {
348
+ const res = await super.call('auth', { token });
349
+ if (res.error)
350
+ throw new Error(`computer-helper auth failed: ${res.error.code}`);
351
+ }
352
+ async call(method, params) {
353
+ if (method !== 'auth') {
354
+ try {
355
+ await this.authReady;
356
+ }
357
+ catch (e) {
358
+ return { id: null, error: { code: 'auth_failed', message: e.message } };
359
+ }
360
+ }
361
+ return super.call(method, params);
362
+ }
363
+ send(payload) {
364
+ this.sock.write(payload);
365
+ }
366
+ async close() {
367
+ if (this.closed)
368
+ return;
369
+ this.sock.end();
370
+ await new Promise((resolve) => {
371
+ if (this.closed)
372
+ return resolve();
373
+ this.sock.on('close', () => resolve());
374
+ });
375
+ }
376
+ }
300
377
  class StdioClient extends BaseClient {
301
378
  proc;
302
379
  constructor(helperPath) {
@@ -324,8 +401,14 @@ class StdioClient extends BaseClient {
324
401
  }
325
402
  }
326
403
  // Describe which transport is currently in use. Useful for diagnostics
327
- // like `agents computer status`.
404
+ // like `agents computer status`. TCP takes precedence to match
405
+ // openComputerClient() — when COMPUTER_HELPER_TCP is set we drive a remote
406
+ // (Windows) daemon over a tunnel, so callers off macOS (no socket, no local
407
+ // .app) must not be told "no transport". `path` is null: the endpoint is an
408
+ // env-configured host:port, not an on-disk path.
328
409
  export function describeTransport() {
410
+ if (resolveTcpEndpoint())
411
+ return { kind: 'tcp', path: null };
329
412
  const sockPath = resolveSocketPath();
330
413
  if (fs.existsSync(sockPath))
331
414
  return { kind: 'socket', path: sockPath };
package/dist/lib/exec.js CHANGED
@@ -534,6 +534,20 @@ export function buildExecCommand(options) {
534
534
  cmd.push('--dangerously-bypass-approvals-and-sandbox');
535
535
  }
536
536
  }
537
+ else if (options.agent === 'kimi' && !interactive) {
538
+ // kimi's headless prompt mode (`-p`/`--prompt`) is self-contained and REFUSES
539
+ // to be combined with any startup-mode flag: `--plan`, `--auto`, and `--yolo`
540
+ // all abort with "Cannot combine --prompt with --X" (verified against the live
541
+ // kimi CLI). The write-capable modes (edit/auto/skip) all collapse to kimi's
542
+ // default `-p` behavior, which already auto-approves tool calls, so we emit no
543
+ // mode flag. Plan (read-only) has no headless equivalent, so fail closed rather
544
+ // than silently letting a plan-mode run mutate the workspace.
545
+ if (resolvedMode === 'plan') {
546
+ throw new Error('kimi has no headless read-only mode: `--prompt` cannot be combined with `--plan`. ' +
547
+ 'Run kimi in plan mode interactively (omit the prompt), or use --mode edit, auto, or skip.');
548
+ }
549
+ // edit/auto/skip: emit no mode flag — `kimi -p` auto-runs.
550
+ }
537
551
  else {
538
552
  cmd.push(...modeFlags);
539
553
  }