impel-cli 0.19.2-beta.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -228,6 +228,30 @@ impel remote dispatch --provider claude --session <session-id> --fork
228
228
  impel remote dispatch --provider codex --session <session-id> --fork
229
229
  ```
230
230
 
231
+ The installable `remote-control` skill turns this into an in-app handoff. Ask
232
+ Claude Code or Codex to run the current session remotely, or invoke the skill
233
+ directly, and it resolves the current Claude session/Codex thread id before
234
+ running:
235
+
236
+ ```sh
237
+ impel remote handoff . --provider <codex|claude> --session <session-id>
238
+ ```
239
+
240
+ Unlike `up` or `dispatch`, `handoff` launches a detached headless worker inside
241
+ Fargate and follows the provider's structured event stream. Inference, tools,
242
+ commands, and edits execute in the runner; the local app only displays the
243
+ stream. Normal completion stops the task, revokes its disposable PAT, and
244
+ removes its SSH alias. To let the task continue without keeping the stream
245
+ open, add `--detach`, then reconnect later:
246
+
247
+ ```sh
248
+ impel remote follow <run-id>
249
+ ```
250
+
251
+ If the viewer disconnects, the detached worker keeps running and its normal
252
+ session hooks continue syncing the remote transcript to `impel-sessions`. The
253
+ task TTL remains the fallback stop and credential-revocation boundary.
254
+
231
255
  Remote vendor commands run in dangerous/bypass mode as the unprivileged
232
256
  `agent` user; the disposable Fargate task is the isolation boundary. Existing
233
257
  Impel lifecycle hooks continue mirroring remote transcripts to
@@ -326,6 +350,37 @@ impel tasks delete IMP-123 --yes
326
350
  Use `--json` when exact machine-readable fields matter. Gateway-only members
327
351
  cannot use task CRUD.
328
352
 
353
+ ### Native Tasks MCP App
354
+
355
+ Setup and update register a second tenant-bound MCP server named
356
+ `impel-tasks` in Impel-managed Claude and Codex/ChatGPT profiles. It is separate
357
+ from the existing `impel` specialist server: removing or disabling Tasks does
358
+ not change specialist routing or approvals.
359
+
360
+ The managed entry launches an absolute, upgrade-stable invocation equivalent
361
+ to:
362
+
363
+ ```sh
364
+ impel mcp --target tasks --tenant <fixed-tenant>
365
+ ```
366
+
367
+ This is an internal transport command, not an interactive PAT interface. The
368
+ child process reads the PAT from Impel's private config, sends it only as the
369
+ control-plane HTTP bearer credential, and fixes tenant selection in the
370
+ transport header. Generated Claude/Codex MCP entries contain neither the PAT
371
+ nor a tenant-derived credential.
372
+
373
+ - The pinned Impel Claude Desktop can render the standard MCP Apps Tasks
374
+ resource when its host supports that protocol.
375
+ - Claude Code and stable Codex receive complete headless task tools and text
376
+ results; no experimental Codex MCP Apps flag is enabled.
377
+ - Profiles preserve unrelated MCP servers. If a user-authored server already
378
+ owns the `impel-tasks` name, setup/update fails with remediation instead of
379
+ overwriting it.
380
+ - The Tasks bridge accepts Streamable HTTP's required JSON/SSE media types but
381
+ deliberately requires bounded JSON responses from the control plane. It
382
+ does not establish an SSE session or persist protocol state.
383
+
329
384
  ## PAT lifecycle
330
385
 
331
386
  Minting and revocation remain explicit account operations:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.19.2-beta.0",
3
+ "version": "0.20.0",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -12,15 +12,6 @@ import path from "node:path";
12
12
  import { normalizeGatewayUrl, redactSecretText } from "./config.js";
13
13
  import { impelMcpInvocation } from "./selfInvocation.js";
14
14
  import { normalizeTenantId } from "./tenants.js";
15
- import {
16
- adapterCallerSpawnGuidance,
17
- adapterHardCompletionGuidance,
18
- adapterSoftCompletionGuidance,
19
- claudeHardCompletionGuidance,
20
- claudeSoftCompletionGuidance,
21
- customAgentVerbatimDescriptionLead,
22
- usesVerbatimRelay,
23
- } from "./verbatimRelay.js";
24
15
  import { renameWithWindowsRetry } from "./windowsFs.js";
25
16
 
26
17
  export const AGENT_SYNC_TTL_MS = 6 * 60 * 60 * 1000;
@@ -30,7 +21,7 @@ export const NATIVE_AGENT_LIST_TOOL = "impel_specialists-list_native_agents";
30
21
  export const NATIVE_AGENT_START_TOOL = "impel_specialists-start_native_agent_run";
31
22
  export const NATIVE_AGENT_READ_TOOL = "impel_specialists-read_native_agent_run";
32
23
  export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
33
- export const MANAGED_AGENT_MANIFEST_VERSION = 5;
24
+ export const MANAGED_AGENT_MANIFEST_VERSION = 4;
34
25
 
35
26
  const NATIVE_AGENT_TOOL_NAMES = [
36
27
  NATIVE_AGENT_LIST_TOOL,
@@ -134,9 +125,6 @@ export function normalizeNativeAgentCatalog(payload, expectedTenantId) {
134
125
  throw new Error(`native-agent catalog returned duplicate binding ${binding}`);
135
126
  }
136
127
  seenBindings.add(binding);
137
- if (agent.verbatimRelay !== undefined && typeof agent.verbatimRelay !== "boolean") {
138
- throw new Error("native-agent catalog returned an invalid verbatimRelay");
139
- }
140
128
  return {
141
129
  agentId,
142
130
  title: boundedString(agent.title, "title", { max: 160 }),
@@ -147,7 +135,6 @@ export function normalizeNativeAgentCatalog(payload, expectedTenantId) {
147
135
  exclusions: stringList(agent.exclusions, "exclusions"),
148
136
  requiredContext: stringList(agent.requiredContext, "requiredContext"),
149
137
  sideEffects: enumString(agent.sideEffects, "sideEffects", ["read-only", "writes"]),
150
- ...(agent.verbatimRelay === true ? { verbatimRelay: true } : {}),
151
138
  };
152
139
  });
153
140
  return { orgId: tenantId, agents };
@@ -342,21 +329,14 @@ function claudeAdapterInstructions(tenantId, agent) {
342
329
  const sideEffectInstruction = agent.sideEffects === "writes"
343
330
  ? `The catalog declares that this agent writes user or workspace data. Use this adapter only when the user explicitly selected this exact agent for the current request; that explicit selection is the required side-effect confirmation, so pass confirmedSideEffects true. If the agent was chosen automatically or the selection is ambiguous, do not start it and ask the user to select it explicitly.`
344
331
  : `The catalog declares that this agent is read-only; omit confirmedSideEffects.`;
345
- const callerSpawnGuidance = usesVerbatimRelay(agent)
346
- ? adapterCallerSpawnGuidance("Claude")
347
- : null;
348
- const completionGuidance = usesVerbatimRelay(agent)
349
- ? claudeHardCompletionGuidance()
350
- : claudeSoftCompletionGuidance();
351
332
  return [
352
333
  `You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
353
- ...(callerSpawnGuidance ? [callerSpawnGuidance] : []),
354
334
  `Do not perform the assigned task yourself and do not delegate to any other agent.`,
355
335
  `First call ${toolName(NATIVE_AGENT_LIST_TOOL)} and verify that the exact agentId is still available with sideEffects ${JSON.stringify(agent.sideEffects)}. If it is unavailable or its policy excludes the request, stop with that explicit error.`,
356
336
  sideEffectInstruction,
357
337
  `Call ${toolName(NATIVE_AGENT_START_TOOL)} exactly once with agentId ${JSON.stringify(agent.agentId)}, scopeParam ${JSON.stringify(agent.scopeParam)}, task set to the complete assigned task, optional context set to one string containing all supplied context (omit it when no context was supplied), contextKeys naming the context fields present in that string, confirmedSideEffects as directed above, and one stable idempotencyKey that you reuse for this logical task.${contextRequirement}`,
358
338
  `Then call ${toolName(NATIVE_AGENT_READ_TOOL)} with the returned runId and waitSeconds 20 until the run reaches a terminal state.`,
359
- completionGuidance,
339
+ `When it succeeds, return result.finalText faithfully as the answer. When it fails, return the durable runId, preserved output, and error. Never invent or independently synthesize a replacement result.`,
360
340
  ].join(" ");
361
341
  }
362
342
 
@@ -514,36 +494,22 @@ function codexAdapterInstructions(tenantId, agent) {
514
494
  ? `The catalog declares that this agent writes user or workspace data. Use this adapter only when the user explicitly selected this exact agent for the current request; that explicit selection is the required side-effect confirmation. If the agent was chosen automatically or the selection is ambiguous, do not run the orchestration and ask the user to select this exact agent explicitly.`
515
495
  : `The catalog declares that this agent is read-only; the orchestration omits confirmedSideEffects.`;
516
496
  const source = renderCodexAdapterOrchestration(tenantId, agent);
517
- const callerSpawnGuidance = usesVerbatimRelay(agent)
518
- ? adapterCallerSpawnGuidance("Codex")
519
- : null;
520
- const completionGuidance = usesVerbatimRelay(agent)
521
- ? adapterHardCompletionGuidance()
522
- : adapterSoftCompletionGuidance();
523
497
  return [
524
498
  `You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
525
- ...(callerSpawnGuidance ? [callerSpawnGuidance] : []),
499
+ `Callers must spawn this explicit custom Codex agent with fork_turns="none" and must relay your result verbatim; this is caller guidance and cannot enforce host spawn behavior.`,
526
500
  `Do not perform the assigned task yourself, do not delegate to any other agent, and do not independently synthesize or rewrite the result.`,
527
501
  `Confirm that the assigned request fits the cataloged capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)} before starting.${contextRequirement}`,
528
502
  sideEffectInstruction,
529
503
  `Invoke functions.exec exactly once for the orchestration below. Do not call the MCP tools directly or select a separate MCP call for any poll. Replace ${CODEX_TASK_PLACEHOLDER} with a JSON string literal for the complete assigned task. Replace ${CODEX_CONTEXT_PLACEHOLDER} with one JSON string literal containing all supplied caller context, or with null when no context was supplied; never use an object or array. Replace ${CODEX_CONTEXT_KEYS_PLACEHOLDER} with a JSON array naming the context fields present in that string, or [] when context is absent. Replace ${CODEX_IDEMPOTENCY_KEY_PLACEHOLDER} with one new opaque idempotency key for this logical invocation: choose it exactly once, reuse it unchanged for any retry of this invocation, and never reuse it for a separate request even when task and context are identical. Then pass the raw JavaScript without Markdown fences.`,
530
504
  `The JavaScript validates the exact tenant, agent binding, and catalog policy; passes the one stable logical-invocation idempotencyKey; starts exactly once; and polls deterministically with the compatible 20-second server wait until status is succeeded or failed. If functions.exec yields a running cell, use functions.wait with max_tokens 30000 only to resume that same orchestration; never start another orchestration or poll the MCP tool yourself.`,
531
- completionGuidance,
505
+ `After the orchestration completes, return its single text output verbatim with no preface, rewriting, Markdown changes, or independent synthesis. A successful output is result.finalText exactly. A failure output preserves the durable runId, output, and error.`,
532
506
  "",
533
507
  source,
534
508
  ].join("\n\n");
535
509
  }
536
510
 
537
- function customAgentDescription(tenantId, agent) {
538
- const sideEffectsLabel = agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)";
539
- return (usesVerbatimRelay(agent)
540
- ? `${customAgentVerbatimDescriptionLead()}. Runs ${agent.title} for Impel tenant ${tenantId}${sideEffectsLabel}: ${agent.description}`
541
- : `Explicitly runs ${agent.title} for Impel tenant ${tenantId}${sideEffectsLabel}: ${agent.description}`
542
- ).slice(0, 900);
543
- }
544
-
545
511
  function renderClaudeAgent({ tenantId, agent, name, invocation }) {
546
- const description = customAgentDescription(tenantId, agent);
512
+ const description = `Explicitly runs ${agent.title} for Impel tenant ${tenantId}${agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)"}: ${agent.description}`.slice(0, 900);
547
513
  const lines = [
548
514
  "---",
549
515
  `name: ${JSON.stringify(name)}`,
@@ -568,7 +534,7 @@ function renderClaudeAgent({ tenantId, agent, name, invocation }) {
568
534
  }
569
535
 
570
536
  function renderCodexAgent({ tenantId, agent, name, invocation }) {
571
- const description = customAgentDescription(tenantId, agent);
537
+ const description = `Explicit custom agent: callers must use fork_turns="none" and relay its result verbatim. Runs ${agent.title} for Impel tenant ${tenantId}${agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)"}: ${agent.description}`.slice(0, 900);
572
538
  const envEntries = Object.entries(invocation.env || {})
573
539
  .map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`)
574
540
  .join(", ");
@@ -662,7 +628,7 @@ export function syncAgentProfile({ client, root, label, tenantId, agents, now =
662
628
  const prior = readManifest(manifestPath);
663
629
  const rendered = renderManagedAgents(client, tenantId, agents);
664
630
  const priorFiles = new Set(prior?.files || []);
665
- const priorUsesDiscoveryRoot = [2, 3, 4, MANAGED_AGENT_MANIFEST_VERSION].includes(prior?.version);
631
+ const priorUsesDiscoveryRoot = [2, 3, MANAGED_AGENT_MANIFEST_VERSION].includes(prior?.version);
666
632
 
667
633
  // Native clients discover standalone definitions directly under `agents/`.
668
634
  // Preflight every destination before writing so an unmanaged file with the
package/src/apps.js CHANGED
@@ -10,13 +10,18 @@ import {
10
10
  secureManagedCodexHome,
11
11
  } from "./codexSecurity.js";
12
12
  import { normalizeTenantId } from "./tenants.js";
13
- import { IMPEL_CLI_ENTRYPOINT, impelCliInvocation } from "./selfInvocation.js";
13
+ import {
14
+ IMPEL_CLI_ENTRYPOINT,
15
+ IMPEL_TASKS_MCP_SERVER_NAME,
16
+ impelCliInvocation,
17
+ impelTasksMcpInvocation,
18
+ isImpelTasksMcpInvocation,
19
+ } from "./selfInvocation.js";
14
20
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
15
21
  import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
16
22
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
17
23
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
18
24
  import { brandedEnvironmentName, RUNTIME_BRAND } from "./runtimeBrand.js";
19
- import { IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS } from "./commands/launch.js";
20
25
 
21
26
  export const CLAUDE_CONFIG_ID = "1ced0000-0000-4000-8000-000000000001";
22
27
  const CHATGPT_CONFIG_START = `# >>> ${RUNTIME_BRAND.cli.command} app managed gateway >>>`;
@@ -267,7 +272,8 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
267
272
  // 26: remember the managed provider defaults and move legacy GPT-5.5 profiles
268
273
  // onto GPT-5.6 Sol so ChatGPT's compact Work picker does not fall back to its
269
274
  // unsupported-selection "Reset to default" treatment.
270
- // 27: add managed ChatGPT parent delegation instructions, including custom-agent verbatim relay opt-in.
275
+ // 27: register the separate tenant-bound Tasks MCP server in managed Claude
276
+ // and ChatGPT/Codex profiles without changing the specialist MCP server.
271
277
  export const CURRENT_CONFIG_VERSION = 27;
272
278
 
273
279
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
@@ -809,7 +815,7 @@ export function installManagedAppFiles({
809
815
  }
810
816
  if (target === "claude") {
811
817
  migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
812
- writeClaudeNative3PSelection(paths);
818
+ writeClaudeNative3PSelection(paths, config);
813
819
  writeClaudeCodeSettings(paths);
814
820
  writeClaudeConfig(paths, config, models);
815
821
  if (RUNTIME_BRAND.features.sessions) ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
@@ -1155,7 +1161,7 @@ function writeClaudeCodeSettings(paths) {
1155
1161
  writeAtomic(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
1156
1162
  }
1157
1163
 
1158
- function writeClaudeNative3PSelection(paths) {
1164
+ function writeClaudeNative3PSelection(paths, config) {
1159
1165
  const configPath = path.join(paths.claude.userData, "claude_desktop_config.json");
1160
1166
  let current = {};
1161
1167
  if (fs.existsSync(configPath)) {
@@ -1168,15 +1174,64 @@ function writeClaudeNative3PSelection(paths) {
1168
1174
  if (!current || typeof current !== "object" || Array.isArray(current)) {
1169
1175
  throw new Error(`Claude desktop config must contain a JSON object: ${configPath}`);
1170
1176
  }
1171
- // Mirror the pinned app's native "select 3P" persistence contract. This
1172
- // marks the first-run choice complete and clears a pending 1P sign-in while
1173
- // leaving Claude-owned preferences (including sidebarMode) untouched.
1174
- if (current.deploymentMode === "3p" && !Object.hasOwn(current, "awaitingSignIn")) return;
1177
+ if (Object.hasOwn(current, "mcpServers") && (
1178
+ !current.mcpServers
1179
+ || typeof current.mcpServers !== "object"
1180
+ || Array.isArray(current.mcpServers)
1181
+ )) {
1182
+ throw new Error(`Claude desktop config has an invalid mcpServers value: ${configPath}`);
1183
+ }
1184
+ const currentMcpServers = current.mcpServers
1185
+ && typeof current.mcpServers === "object"
1186
+ && !Array.isArray(current.mcpServers)
1187
+ ? current.mcpServers
1188
+ : {};
1189
+ const currentTasksServer = currentMcpServers[IMPEL_TASKS_MCP_SERVER_NAME];
1190
+ if (RUNTIME_BRAND.features.mcp && config.tenantId) {
1191
+ if (Object.hasOwn(currentMcpServers, IMPEL_TASKS_MCP_SERVER_NAME)
1192
+ && !isImpelTasksMcpInvocation(currentTasksServer)) {
1193
+ throw new Error(
1194
+ `${configPath} already has an mcpServers.${IMPEL_TASKS_MCP_SERVER_NAME} entry `
1195
+ + "that wasn't written by impel-cli. Remove or rename it, then re-run."
1196
+ );
1197
+ }
1198
+ }
1199
+
1200
+ // Mirror the pinned app's native "select 3P" persistence contract and add
1201
+ // only the tenant-bound Tasks entry we own. Claude-owned preferences and
1202
+ // foreign MCP entries remain untouched.
1175
1203
  const next = { ...current, deploymentMode: "3p" };
1176
1204
  delete next.awaitingSignIn;
1205
+ if (RUNTIME_BRAND.features.mcp && config.tenantId) {
1206
+ next.mcpServers = {
1207
+ ...currentMcpServers,
1208
+ [IMPEL_TASKS_MCP_SERVER_NAME]: impelTasksMcpInvocation(config.tenantId),
1209
+ };
1210
+ } else if (isImpelTasksMcpInvocation(currentTasksServer)) {
1211
+ next.mcpServers = { ...currentMcpServers };
1212
+ delete next.mcpServers[IMPEL_TASKS_MCP_SERVER_NAME];
1213
+ if (Object.keys(next.mcpServers).length === 0) delete next.mcpServers;
1214
+ }
1177
1215
  writeAtomic(configPath, `${JSON.stringify(next, null, 2)}\n`, 0o600);
1178
1216
  }
1179
1217
 
1218
+ function tasksInvocationFromBaseMcp(mcp, tenantId) {
1219
+ if (!mcp || typeof mcp.command !== "string" || !Array.isArray(mcp.args)) {
1220
+ return impelTasksMcpInvocation(tenantId);
1221
+ }
1222
+ const mcpIndex = mcp.args.lastIndexOf("mcp");
1223
+ if (mcpIndex === -1) return impelTasksMcpInvocation(tenantId);
1224
+ return {
1225
+ command: mcp.command,
1226
+ args: [
1227
+ ...mcp.args.slice(0, mcpIndex + 1),
1228
+ "--target",
1229
+ "tasks",
1230
+ ...mcp.args.slice(mcpIndex + 1),
1231
+ ],
1232
+ };
1233
+ }
1234
+
1180
1235
  function writeChatGPTConfig(
1181
1236
  paths,
1182
1237
  config,
@@ -1258,6 +1313,25 @@ function writeChatGPTConfig(
1258
1313
  command: mcpInvocation.command,
1259
1314
  args: [...mcpInvocation.args],
1260
1315
  };
1316
+ const tasksMcp = invocations?.tasksMcp
1317
+ || tasksInvocationFromBaseMcp(mcp, config.tenantId);
1318
+
1319
+ if (RUNTIME_BRAND.features.mcp && config.tenantId) {
1320
+ const unmanagedToml = stripManagedChatGPTToml(currentToml);
1321
+ const tasksName = escapeRegex(IMPEL_TASKS_MCP_SERVER_NAME);
1322
+ const mcpServersKey = `(?:mcp_servers|"mcp_servers"|'mcp_servers')`;
1323
+ const tasksKey = `(?:${tasksName}|"${tasksName}"|'${tasksName}')`;
1324
+ const foreignTasksTable = new RegExp(
1325
+ `^[ \\t]*\\[[ \\t]*${mcpServersKey}[ \\t]*\\.[ \\t]*${tasksKey}(?:[ \\t]*\\.[ \\t]*|[ \\t]*\\])`,
1326
+ "mu",
1327
+ );
1328
+ if (foreignTasksTable.test(unmanagedToml)) {
1329
+ throw new Error(
1330
+ `${configPath} already has an mcp_servers.${IMPEL_TASKS_MCP_SERVER_NAME} table `
1331
+ + "outside the impel-cli managed block. Remove or rename it, then re-run."
1332
+ );
1333
+ }
1334
+ }
1261
1335
 
1262
1336
  const managedToml = [
1263
1337
  CHATGPT_CONFIG_START,
@@ -1271,9 +1345,6 @@ function writeChatGPTConfig(
1271
1345
  `model_catalog_json = ${tomlString(paths.chatgpt.catalog)}`,
1272
1346
  ...(selectedEffort ? [`model_reasoning_effort = ${tomlString(selectedEffort)}`] : []),
1273
1347
  ...(selectedTier ? [`service_tier = ${tomlString(selectedTier)}`] : []),
1274
- ...(RUNTIME_BRAND.features.agents
1275
- ? [`developer_instructions = ${tomlString(IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS)}`]
1276
- : []),
1277
1348
  "",
1278
1349
  // The built-in ChatGPT provider derives its inference endpoint from
1279
1350
  // chatgpt.com even when chatgpt_base_url points at the gateway. Keep the
@@ -1301,6 +1372,12 @@ function writeChatGPTConfig(
1301
1372
  `[mcp_servers.${RUNTIME_BRAND.cli.providerId}]`,
1302
1373
  `command = ${tomlString(mcp.command)}`,
1303
1374
  `args = [${mcp.args.map((argument) => tomlString(argument)).join(", ")}]`,
1375
+ ...(config.tenantId ? [
1376
+ "",
1377
+ `[mcp_servers.${IMPEL_TASKS_MCP_SERVER_NAME}]`,
1378
+ `command = ${tomlString(tasksMcp.command)}`,
1379
+ `args = [${tasksMcp.args.map((argument) => tomlString(argument)).join(", ")}]`,
1380
+ ] : []),
1304
1381
  ] : []),
1305
1382
  CHATGPT_CONFIG_END,
1306
1383
  ].join("\n");
@@ -1474,7 +1551,7 @@ function readVendorCodexModels(vendorPath) {
1474
1551
  return new Map();
1475
1552
  }
1476
1553
 
1477
- function mergeManagedChatGPTToml(current, managed) {
1554
+ function stripManagedChatGPTToml(current) {
1478
1555
  let remainder = current;
1479
1556
  const start = current.indexOf(CHATGPT_CONFIG_START);
1480
1557
  const end = current.indexOf(CHATGPT_CONFIG_END);
@@ -1484,7 +1561,11 @@ function mergeManagedChatGPTToml(current, managed) {
1484
1561
  const legacyEnd = current.match(/refresh_interval_ms = \d+\r?\n/u);
1485
1562
  if (legacyEnd?.index != null) remainder = current.slice(legacyEnd.index + legacyEnd[0].length);
1486
1563
  }
1487
- remainder = remainder.replace(/^\s+/u, "");
1564
+ return remainder.replace(/^\s+/u, "");
1565
+ }
1566
+
1567
+ function mergeManagedChatGPTToml(current, managed) {
1568
+ const remainder = stripManagedChatGPTToml(current);
1488
1569
  return `${managed}\n${remainder ? `\n${remainder.replace(/\s*$/u, "")}\n` : ""}`;
1489
1570
  }
1490
1571
 
@@ -10,7 +10,13 @@ import {
10
10
  } from "./codexSecurity.js";
11
11
  import { CONFIG_DIR } from "./config.js";
12
12
  import { normalizeTenantId } from "./tenants.js";
13
- import { impelCliInvocation, impelMcpInvocation } from "./selfInvocation.js";
13
+ import {
14
+ IMPEL_TASKS_MCP_SERVER_NAME,
15
+ impelCliInvocation,
16
+ impelMcpInvocation,
17
+ impelTasksMcpInvocation,
18
+ isImpelTasksMcpInvocation,
19
+ } from "./selfInvocation.js";
14
20
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
15
21
  import { renameWithWindowsRetry } from "./windowsFs.js";
16
22
  import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
@@ -132,13 +138,30 @@ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels
132
138
  settings.env = managedEnvironment;
133
139
  applyImpelClaudeSandbox(settings);
134
140
  if (RUNTIME_BRAND.features.mcp) {
141
+ if (Object.hasOwn(userConfig, "mcpServers") && (
142
+ !userConfig.mcpServers
143
+ || typeof userConfig.mcpServers !== "object"
144
+ || Array.isArray(userConfig.mcpServers)
145
+ )) {
146
+ throw new Error(`${userConfigPath} has an invalid mcpServers value. Fix or remove it, then re-run.`);
147
+ }
148
+ const existingServers = userConfig.mcpServers
149
+ && typeof userConfig.mcpServers === "object"
150
+ && !Array.isArray(userConfig.mcpServers)
151
+ ? userConfig.mcpServers
152
+ : {};
153
+ const existingTasksServer = existingServers[IMPEL_TASKS_MCP_SERVER_NAME];
154
+ if (Object.hasOwn(existingServers, IMPEL_TASKS_MCP_SERVER_NAME)
155
+ && !isImpelTasksMcpInvocation(existingTasksServer)) {
156
+ throw new Error(
157
+ `${userConfigPath} already has an mcpServers.${IMPEL_TASKS_MCP_SERVER_NAME} entry `
158
+ + "that wasn't written by impel-cli. Remove or rename it, then re-run."
159
+ );
160
+ }
135
161
  userConfig.mcpServers = {
136
- ...(userConfig.mcpServers &&
137
- typeof userConfig.mcpServers === "object" &&
138
- !Array.isArray(userConfig.mcpServers)
139
- ? userConfig.mcpServers
140
- : {}),
162
+ ...existingServers,
141
163
  [RUNTIME_BRAND.cli.providerId]: impelMcpInvocation(["--tenant", tenantId]),
164
+ [IMPEL_TASKS_MCP_SERVER_NAME]: impelTasksMcpInvocation(tenantId),
142
165
  };
143
166
  }
144
167
 
@@ -194,6 +217,13 @@ function codexManagedBlock(gatewayUrl, tenantId) {
194
217
  "",
195
218
  );
196
219
  }
220
+ const tasksMcp = impelTasksMcpInvocation(tenantId);
221
+ lines.push(
222
+ `[mcp_servers.${IMPEL_TASKS_MCP_SERVER_NAME}]`,
223
+ `command = ${JSON.stringify(tasksMcp.command)}`,
224
+ `args = [${tasksMcp.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
225
+ "",
226
+ );
197
227
  }
198
228
  lines.push(CODEX_END_MARK);
199
229
  return lines.join("\n");
@@ -208,9 +238,18 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
208
238
  const withoutManagedBlock = stripCodexManagedBlock(original, configPath);
209
239
 
210
240
  const providerPattern = RUNTIME_BRAND.cli.providerId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
211
- if (new RegExp(`^(\\[model_providers\\.${providerPattern}(?:\\.|\\])|\\[mcp_servers\\.${providerPattern}\\])`, "m").test(withoutManagedBlock)) {
241
+ const tasksPattern = IMPEL_TASKS_MCP_SERVER_NAME.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
242
+ const providerKey = `(?:${providerPattern}|"${providerPattern}"|'${providerPattern}')`;
243
+ const tasksKey = `(?:${tasksPattern}|"${tasksPattern}"|'${tasksPattern}')`;
244
+ const modelProvidersKey = `(?:model_providers|"model_providers"|'model_providers')`;
245
+ const mcpServersKey = `(?:mcp_servers|"mcp_servers"|'mcp_servers')`;
246
+ const dot = "[ \\t]*\\.[ \\t]*";
247
+ if (new RegExp(
248
+ `^[ \\t]*\\[[ \\t]*(?:${modelProvidersKey}${dot}${providerKey}|${mcpServersKey}${dot}(?:${providerKey}|${tasksKey}))(?:${dot}|[ \\t]*\\])`,
249
+ "m",
250
+ ).test(withoutManagedBlock)) {
212
251
  throw new Error(
213
- `${configPath} contains a ${RUNTIME_BRAND.product.displayName} provider or MCP table outside the managed profile block. ` +
252
+ `${configPath} contains a ${RUNTIME_BRAND.product.displayName} provider or managed MCP table outside the managed profile block. ` +
214
253
  "Remove or rename that table, then re-run."
215
254
  );
216
255
  }
@@ -14,7 +14,6 @@ import {
14
14
  resolveDefaultGateway,
15
15
  saveConfig,
16
16
  } from "../config.js";
17
- import { parentVerbatimRelayAppendix } from "../verbatimRelay.js";
18
17
  import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.js";
19
18
  import { withGitEnvironment } from "../skills.js";
20
19
  import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
@@ -50,18 +49,10 @@ export {
50
49
 
51
50
  export const IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS = `You are running in an Impel tenant-scoped session. Before starting any non-trivial task, call the Impel MCP tool list_specialists. If exactly one available specialist clearly matches the user's request, its capabilities and its exclusions, delegate the complete request by calling start_specialist_run exactly once with a stable idempotency key, then call read_specialist_run until it reaches a terminal state. When the run succeeds, use the specialist's result as your response instead of redoing the work. If no specialist clearly matches, the tools are unavailable, or the run fails, continue normally yourself. Do not delegate trivial requests, do not call a specialist excluded from the request, and never invent a specialist result.`;
52
51
 
53
- export const IMPEL_CUSTOM_AGENT_VERBATIM_RELAY_APPENDIX = parentVerbatimRelayAppendix();
54
-
55
- export const IMPEL_CLAUDE_PARENT_DELEGATION_INSTRUCTIONS =
56
- `${IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS} ${IMPEL_CUSTOM_AGENT_VERBATIM_RELAY_APPENDIX}`;
57
-
58
52
  export const IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS =
59
53
  `${IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS} ` +
60
54
  `Codex can defer MCP tools behind tool_search. If an Impel specialist tool is not directly visible, call tool_search for its exact name before treating it as unavailable: impel_specialists-list_specialists for discovery, impel_specialists-start_specialist_run to delegate, and impel_specialists-read_specialist_run to poll the result. Use the returned tool for the same one-run delegation flow.`;
61
55
 
62
- export const IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS =
63
- `${IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS} ${IMPEL_CUSTOM_AGENT_VERBATIM_RELAY_APPENDIX}`;
64
-
65
56
  const IMPEL_CODEX_RUNTIME_OVERRIDES = [
66
57
  // Codex models may select code mode even when the standalone
67
58
  // `codex-code-mode-host` companion is not present in the vendor install.
@@ -73,14 +64,14 @@ const IMPEL_CODEX_RUNTIME_OVERRIDES = [
73
64
  export function impelLaunchArguments(tool, argv) {
74
65
  if (!RUNTIME_BRAND.features.agents) return [...argv];
75
66
  if (tool === "claude") {
76
- return ["--append-system-prompt", IMPEL_CLAUDE_PARENT_DELEGATION_INSTRUCTIONS, ...argv];
67
+ return ["--append-system-prompt", IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS, ...argv];
77
68
  }
78
69
  if (tool === "codex") {
79
70
  // `-c` is a Codex global option, so it must precede subcommands such as
80
71
  // `exec`, `resume`, and `mcp`. JSON strings are valid TOML basic strings.
81
72
  return [
82
73
  "-c",
83
- `developer_instructions=${JSON.stringify(IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS)}`,
74
+ `developer_instructions=${JSON.stringify(IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS)}`,
84
75
  ...IMPEL_CODEX_RUNTIME_OVERRIDES.flatMap((override) => ["-c", override]),
85
76
  ...argv,
86
77
  ];
@@ -1,9 +1,20 @@
1
1
  import readline from "node:readline";
2
2
 
3
- import { loadConfig, resolveDefaultGateway } from "../config.js";
3
+ import {
4
+ loadConfig,
5
+ normalizeGatewayUrl,
6
+ redactSecretText,
7
+ resolveDefaultAppUrl,
8
+ resolveDefaultGateway,
9
+ } from "../config.js";
4
10
  import { parseFlags } from "../args.js";
5
11
  import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
6
12
 
13
+ const TASKS_TARGET = "tasks";
14
+ const TASKS_TENANT_HEADER = "X-Impel-Tenant";
15
+ const TASKS_ERROR_MESSAGE_MAX_LENGTH = 512;
16
+ const TASKS_ERROR_TOKEN_MAX_LENGTH = 128;
17
+
7
18
  function rpcError(id, message) {
8
19
  return JSON.stringify({
9
20
  jsonrpc: "2.0",
@@ -12,6 +23,28 @@ function rpcError(id, message) {
12
23
  });
13
24
  }
14
25
 
26
+ function tasksJsonRpcRequestId(message) {
27
+ if (
28
+ !message
29
+ || typeof message !== "object"
30
+ || Array.isArray(message)
31
+ || message.jsonrpc !== "2.0"
32
+ || typeof message.method !== "string"
33
+ ) {
34
+ return { valid: false, id: null };
35
+ }
36
+ if (!Object.hasOwn(message, "id")) return { valid: true, id: null };
37
+ const id = message.id;
38
+ if (
39
+ id === null
40
+ || typeof id === "string"
41
+ || (typeof id === "number" && Number.isFinite(id))
42
+ ) {
43
+ return { valid: true, id };
44
+ }
45
+ return { valid: false, id: null };
46
+ }
47
+
15
48
  function responseLines(contentType, body) {
16
49
  if (!contentType.toLowerCase().includes("text/event-stream")) {
17
50
  return body.trim() ? [JSON.stringify(JSON.parse(body))] : [];
@@ -24,18 +57,146 @@ function responseLines(contentType, body) {
24
57
  .map((line) => JSON.stringify(JSON.parse(line)));
25
58
  }
26
59
 
60
+ function isApplicationJson(contentType) {
61
+ return contentType.split(";", 1)[0].trim().toLowerCase() === "application/json";
62
+ }
63
+
64
+ function jsonResponseLines(contentType, body) {
65
+ if (!body.trim()) return [];
66
+ if (!isApplicationJson(contentType)) {
67
+ throw new Error("Impel Tasks MCP returned a non-JSON response.");
68
+ }
69
+ try {
70
+ return [JSON.stringify(JSON.parse(body))];
71
+ } catch {
72
+ throw new Error("Impel Tasks MCP returned invalid JSON.");
73
+ }
74
+ }
75
+
76
+ function tasksHttpError(status) {
77
+ if (status === 401 || status === 403) {
78
+ return `Impel Tasks MCP authentication failed (HTTP ${status}). Run \`impel setup\` to refresh access.`;
79
+ }
80
+ return `Impel Tasks MCP returned HTTP ${status}.`;
81
+ }
82
+
83
+ function loadTasksMcpConnection() {
84
+ const config = loadConfig();
85
+ if (
86
+ !config
87
+ || typeof config.pat !== "string"
88
+ || !config.pat.trim()
89
+ || config.pat !== config.pat.trim()
90
+ ) {
91
+ throw new Error("not authenticated; run `impel setup` (or `impel auth`) first");
92
+ }
93
+ const appUrl = normalizeGatewayUrl(config.appUrl || resolveDefaultAppUrl());
94
+ let endpoint;
95
+ try {
96
+ endpoint = new URL("/api/mcp/tasks", `${appUrl}/`);
97
+ } catch {
98
+ throw new Error("Tasks MCP app URL is invalid; run `impel setup` to repair it");
99
+ }
100
+ if (
101
+ !["http:", "https:"].includes(endpoint.protocol)
102
+ || endpoint.username
103
+ || endpoint.password
104
+ ) {
105
+ throw new Error("Tasks MCP app URL is invalid; run `impel setup` to repair it");
106
+ }
107
+ return { credential: config.pat, endpoint: endpoint.href };
108
+ }
109
+
110
+ function safeTasksErrorToken(value) {
111
+ if (
112
+ typeof value !== "string"
113
+ || value.length === 0
114
+ || value.length > TASKS_ERROR_TOKEN_MAX_LENGTH
115
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(value)
116
+ || redactSecretText(value) !== value
117
+ ) {
118
+ return undefined;
119
+ }
120
+ return value;
121
+ }
122
+
123
+ function safeTasksErrorData(value, httpStatus) {
124
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
125
+ const data = {};
126
+ const schemaVersion = safeTasksErrorToken(value.schemaVersion);
127
+ const code = safeTasksErrorToken(value.code);
128
+ if (schemaVersion !== undefined) data.schemaVersion = schemaVersion;
129
+ if (code !== undefined) data.code = code;
130
+ if (Number.isSafeInteger(value.status) && value.status === httpStatus) {
131
+ data.status = value.status;
132
+ }
133
+ if (typeof value.retryable === "boolean") data.retryable = value.retryable;
134
+ return Object.keys(data).length > 0 ? data : undefined;
135
+ }
136
+
137
+ function tasksJsonRpcHttpError(id, contentType, body, httpStatus) {
138
+ if (!isApplicationJson(contentType)) return null;
139
+ let response;
140
+ try {
141
+ response = JSON.parse(body);
142
+ } catch {
143
+ return null;
144
+ }
145
+ if (
146
+ !response
147
+ || typeof response !== "object"
148
+ || Array.isArray(response)
149
+ || response.jsonrpc !== "2.0"
150
+ || !response.error
151
+ || typeof response.error !== "object"
152
+ || Array.isArray(response.error)
153
+ || !Number.isSafeInteger(response.error.code)
154
+ || typeof response.error.message !== "string"
155
+ ) {
156
+ return null;
157
+ }
158
+ const message = redactSecretText(response.error.message).trim();
159
+ if (!message) return null;
160
+ const error = {
161
+ code: response.error.code,
162
+ message: message.length <= TASKS_ERROR_MESSAGE_MAX_LENGTH
163
+ ? message
164
+ : `${message.slice(0, TASKS_ERROR_MESSAGE_MAX_LENGTH - 1)}…`,
165
+ };
166
+ const data = safeTasksErrorData(response.error.data, httpStatus);
167
+ if (data) error.data = data;
168
+ return JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error });
169
+ }
170
+
27
171
  export async function cmdMcp(argv = []) {
28
- const { flags } = parseFlags(argv, { tenant: { type: "string" } });
172
+ const { flags, positionals } = parseFlags(argv, {
173
+ target: { type: "string" },
174
+ tenant: { type: "string" },
175
+ });
176
+ const targetSpecified = Object.hasOwn(flags, "target");
177
+ if (targetSpecified && flags.target !== TASKS_TARGET) {
178
+ throw new Error("unsupported MCP target; expected `--target tasks`");
179
+ }
180
+ const tasksTarget = flags.target === TASKS_TARGET;
181
+ if (tasksTarget) {
182
+ const unsupportedFlags = Object.keys(flags).filter((name) => !["target", "tenant"].includes(name));
183
+ if (unsupportedFlags.length > 0 || positionals.length > 0) {
184
+ throw new Error("unsupported Tasks MCP arguments; use `--target tasks --tenant <tenant>`");
185
+ }
186
+ if (typeof flags.tenant !== "string" || !flags.tenant.trim()) {
187
+ throw new Error("Tasks MCP requires a fixed `--tenant <tenant>` argument");
188
+ }
189
+ }
29
190
  const config = loadConfig();
30
191
  if (!config?.pat) {
31
192
  throw new Error("not authenticated; run `impel setup` (or `impel auth`) first");
32
193
  }
33
- const gatewayUrl = config.gatewayUrl || resolveDefaultGateway();
34
194
  const tenantId = flags.tenant
35
195
  ? normalizeTenantId(flags.tenant)
36
196
  : (await ensureTenantSelection(config)).tenantId;
37
- const gatewayCredential = tenantCredential(config.pat, tenantId);
38
- const endpoint = `${gatewayUrl}/mcp`;
197
+ const gatewayUrl = config.gatewayUrl || resolveDefaultGateway();
198
+ const credential = tasksTarget ? null : tenantCredential(config.pat, tenantId);
199
+ const endpoint = tasksTarget ? null : `${gatewayUrl}/mcp`;
39
200
  const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
40
201
  let sessionId;
41
202
 
@@ -49,19 +210,30 @@ export async function cmdMcp(argv = []) {
49
210
  process.stdout.write(`${rpcError(null, "Invalid JSON-RPC request.")}\n`);
50
211
  continue;
51
212
  }
213
+ const tasksRequest = tasksTarget
214
+ ? tasksJsonRpcRequestId(message)
215
+ : null;
216
+ if (tasksTarget && !tasksRequest.valid) {
217
+ process.stdout.write(`${rpcError(tasksRequest.id, "Invalid JSON-RPC request.")}\n`);
218
+ continue;
219
+ }
52
220
 
53
221
  try {
222
+ const tasksConnection = tasksTarget ? loadTasksMcpConnection() : null;
54
223
  const controller = new AbortController();
55
224
  const timeout = setTimeout(() => controller.abort(), 70_000);
56
225
  let response;
57
226
  try {
58
- response = await fetch(endpoint, {
227
+ response = await fetch(tasksConnection?.endpoint || endpoint, {
59
228
  method: "POST",
60
229
  headers: {
61
- Authorization: `Bearer ${gatewayCredential}`,
230
+ Authorization: `Bearer ${tasksConnection?.credential || credential}`,
62
231
  "Content-Type": "application/json",
232
+ // Streamable HTTP requires clients to advertise both media types
233
+ // even when the server is configured to emit bounded JSON only.
63
234
  Accept: "application/json, text/event-stream",
64
- ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
235
+ ...(tasksTarget ? { [TASKS_TENANT_HEADER]: tenantId } : {}),
236
+ ...(!tasksTarget && sessionId ? { "Mcp-Session-Id": sessionId } : {}),
65
237
  },
66
238
  body: JSON.stringify(message),
67
239
  signal: controller.signal,
@@ -69,26 +241,38 @@ export async function cmdMcp(argv = []) {
69
241
  } finally {
70
242
  clearTimeout(timeout);
71
243
  }
72
- sessionId = response.headers.get("mcp-session-id") || sessionId;
244
+ if (!tasksTarget) sessionId = response.headers.get("mcp-session-id") || sessionId;
73
245
  const body = await response.text();
246
+ const contentType = response.headers.get("content-type")
247
+ || (tasksTarget ? "" : "application/json");
74
248
  if (!response.ok) {
249
+ const tasksError = tasksTarget
250
+ ? tasksJsonRpcHttpError(tasksRequest.id, contentType, body, response.status)
251
+ : null;
75
252
  process.stdout.write(
76
- `${rpcError(message.id, `Impel MCP gateway returned HTTP ${response.status}.`)}\n`
253
+ `${tasksError || rpcError(
254
+ tasksTarget ? tasksRequest.id : message.id,
255
+ tasksTarget
256
+ ? tasksHttpError(response.status)
257
+ : `Impel MCP gateway returned HTTP ${response.status}.`
258
+ )}\n`
77
259
  );
78
260
  continue;
79
261
  }
80
- for (const output of responseLines(
81
- response.headers.get("content-type") || "application/json",
82
- body
83
- )) {
262
+ const outputs = tasksTarget
263
+ ? jsonResponseLines(contentType, body)
264
+ : responseLines(contentType, body);
265
+ for (const output of outputs) {
84
266
  process.stdout.write(`${output}\n`);
85
267
  }
86
268
  } catch (error) {
87
269
  const messageText =
88
270
  error?.name === "AbortError"
89
- ? "Impel MCP gateway timed out."
90
- : `Impel MCP gateway request failed: ${error?.message || error}`;
91
- process.stdout.write(`${rpcError(message.id, messageText)}\n`);
271
+ ? tasksTarget ? "Impel Tasks MCP timed out." : "Impel MCP gateway timed out."
272
+ : tasksTarget
273
+ ? `Impel Tasks MCP request failed: ${redactSecretText(error?.message || error)}`
274
+ : `Impel MCP gateway request failed: ${redactSecretText(error?.message || error)}`;
275
+ process.stdout.write(`${rpcError(tasksTarget ? tasksRequest.id : message.id, messageText)}\n`);
92
276
  }
93
277
  }
94
278
  }
@@ -61,6 +61,8 @@ Usage:
61
61
  impel remote status [run-id] [--json]
62
62
  impel remote attach [run-id] [--provider codex|claude] [--desktop]
63
63
  impel remote dispatch [path] --provider codex|claude --session <id> [--fork]
64
+ impel remote handoff [path] --provider codex|claude --session <id> [--detach]
65
+ impel remote follow [run-id]
64
66
  impel remote proxy <run-id> --port <port>
65
67
  impel remote down [run-id] --yes
66
68
  impel remote down --all --yes
@@ -77,6 +79,9 @@ Lifecycle options:
77
79
  --timeout <seconds> Runner startup timeout from 30 through 900. Default: 300.
78
80
  --json Emit machine-readable state where supported.
79
81
 
82
+ Handoff options:
83
+ --detach Start remote execution and return immediately.
84
+
80
85
  Attach options:
81
86
  --session <id> Resume a transferred provider session.
82
87
  --fork Fork instead of continuing the transferred session.
@@ -85,8 +90,10 @@ Attach options:
85
90
  The live UI uses native SSH: Codex Desktop starts remote codex app-server and can
86
91
  hand off an existing chat and Git state. Claude Desktop can start an SSH session;
87
92
  existing Claude sessions use dispatch/resume because Claude has no arbitrary-host
88
- desktop handoff API. Credentials, ignored files, SSH agents, and environment
89
- variables are never copied implicitly.
93
+ desktop handoff API. Handoff starts a headless worker inside Fargate and streams
94
+ its structured events back; the local app is only the control/viewer process.
95
+ Credentials, ignored files, SSH agents, and environment variables are never
96
+ copied implicitly.
90
97
  `;
91
98
 
92
99
  class RemoteCommandError extends Error {}
@@ -124,6 +131,7 @@ function rejectMissingFlagValues(flags, spec, action) {
124
131
  function lifecycleSpec() {
125
132
  return {
126
133
  env: { type: "string" },
134
+ detach: { type: "boolean" },
127
135
  help: { type: "boolean" },
128
136
  install: { type: "string" },
129
137
  json: { type: "boolean" },
@@ -346,6 +354,47 @@ async function cmdUp(argv, { dispatch = false } = {}) {
346
354
  return state;
347
355
  }
348
356
 
357
+ async function stopAndCleanRun(config, state) {
358
+ const context = awsContext(state);
359
+ if (state.status !== "stopped" && state.aws?.taskArn) {
360
+ try { stopTask(context, state); } catch (error) {
361
+ const current = describeTask(context, state);
362
+ if (current.lastStatus !== "STOPPED") throw error;
363
+ }
364
+ }
365
+ let revokeError = null;
366
+ if (!state.credential?.revokedAt) {
367
+ if (!config?.pat) {
368
+ revokeError = new Error("no local Impel credential is available to revoke the remote PAT");
369
+ } else {
370
+ try { state = await revokeRunCredential(config, state); } catch (error) { revokeError = error; }
371
+ }
372
+ }
373
+ removeSshAlias(state.runId);
374
+ removeRunSecrets(state.runId);
375
+ state = writeRunState({
376
+ ...state,
377
+ status: "stopped",
378
+ stoppedAt: state.stoppedAt || new Date().toISOString(),
379
+ ...(revokeError ? {
380
+ credential: {
381
+ ...state.credential,
382
+ revokeError: redactSecretText(revokeError?.message || revokeError),
383
+ },
384
+ } : {}),
385
+ });
386
+ return { state, revokeError };
387
+ }
388
+
389
+ function printCleanupResult(state, revokeError) {
390
+ if (revokeError) {
391
+ console.warn(`Stopped remote run ${state.runId} and removed its SSH alias, but credential revocation must be retried: ${redactSecretText(revokeError?.message || revokeError)}`);
392
+ process.exitCode = 1;
393
+ } else {
394
+ console.log(`Stopped remote run ${state.runId}; revoked its credential and removed its SSH alias.`);
395
+ }
396
+ }
397
+
349
398
  function syncTaskStatus(state) {
350
399
  if (!state?.aws?.taskArn || ["failed", "stopped"].includes(state.status)) return state;
351
400
  const context = awsContext(state);
@@ -456,6 +505,116 @@ async function cmdDispatch(argv) {
456
505
  ]);
457
506
  }
458
507
 
508
+ function workerCommand(action, state) {
509
+ const command = ["impel-remote-worker", action];
510
+ if (action === "start") {
511
+ command.push(state.provider, state.session.id, state.repository.remoteProjectPath);
512
+ }
513
+ return command.map(shellQuote).join(" ");
514
+ }
515
+
516
+ function readWorkerStatus(state) {
517
+ const result = runCapture(
518
+ process.env.IMPEL_REMOTE_SSH_BIN || "ssh",
519
+ [state.alias, workerCommand("status", state)],
520
+ { allowFailure: true },
521
+ );
522
+ if (result.status !== 0) return null;
523
+ try {
524
+ const value = JSON.parse(result.stdout.trim());
525
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
526
+ } catch {
527
+ return null;
528
+ }
529
+ }
530
+
531
+ async function followRemoteWorker(state) {
532
+ console.log(`Remote execution ${state.runId} is running in Fargate. Streaming structured events:`);
533
+ console.log("");
534
+ runInteractive(
535
+ process.env.IMPEL_REMOTE_SSH_BIN || "ssh",
536
+ [state.alias, workerCommand("follow", state)],
537
+ { allowFailure: true },
538
+ );
539
+ const worker = readWorkerStatus(state);
540
+ if (!worker || !["completed", "failed"].includes(worker.status)) {
541
+ state = writeRunState({
542
+ ...state,
543
+ execution: { ...state.execution, status: "running", lastFollowEndedAt: new Date().toISOString() },
544
+ });
545
+ console.warn(`Remote execution is still running or its status could not be read. Reconnect with: impel remote follow ${state.runId}`);
546
+ return { state, completed: false };
547
+ }
548
+ const exitCode = Number.isInteger(worker.exitCode) ? worker.exitCode : 1;
549
+ state = writeRunState({
550
+ ...state,
551
+ execution: {
552
+ ...state.execution,
553
+ status: worker.status,
554
+ exitCode,
555
+ finishedAt: worker.finishedAt || new Date().toISOString(),
556
+ },
557
+ });
558
+ const cleaned = await stopAndCleanRun(loadConfig(), state);
559
+ printCleanupResult(cleaned.state, cleaned.revokeError);
560
+ if (exitCode !== 0) process.exitCode = exitCode;
561
+ return { state: cleaned.state, completed: true };
562
+ }
563
+
564
+ async function cmdHandoff(argv) {
565
+ const spec = lifecycleSpec();
566
+ const { flags, positionals } = parseFlags(argv, spec);
567
+ rejectFlags(flags, new Set([
568
+ "detach", "env", "help", "install", "json", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl",
569
+ ]), "handoff");
570
+ rejectMissingFlagValues(flags, spec, "handoff");
571
+ if (flags.help) { console.log(HELP); return; }
572
+ if (positionals.length > 1) fail("impel remote handoff: expected at most one repository path");
573
+ if (!flags.session) fail("impel remote handoff: --session is required");
574
+
575
+ let state = await createRemoteRun({ ...flags, path: positionals[0] });
576
+ try {
577
+ runCapture(
578
+ process.env.IMPEL_REMOTE_SSH_BIN || "ssh",
579
+ [state.alias, workerCommand("start", state)],
580
+ );
581
+ state = writeRunState({
582
+ ...state,
583
+ execution: {
584
+ mode: flags.detach ? "detached" : "follow",
585
+ status: "running",
586
+ startedAt: new Date().toISOString(),
587
+ },
588
+ });
589
+ } catch (error) {
590
+ const cleaned = await stopAndCleanRun(loadConfig(), state);
591
+ printCleanupResult(cleaned.state, cleaned.revokeError);
592
+ throw error;
593
+ }
594
+
595
+ if (flags.detach) {
596
+ printRun(state, flags.json === true);
597
+ if (!flags.json) {
598
+ console.log(`Worker: running remotely; follow with impel remote follow ${state.runId}`);
599
+ console.log("Results: the transferred provider session continues syncing through impel-sessions.");
600
+ }
601
+ return;
602
+ }
603
+ await followRemoteWorker(state);
604
+ }
605
+
606
+ async function cmdFollow(argv) {
607
+ const spec = { help: { type: "boolean" } };
608
+ const { flags, positionals } = parseFlags(argv, spec);
609
+ rejectFlags(flags, new Set(["help"]), "follow");
610
+ if (flags.help) { console.log(HELP); return; }
611
+ if (positionals.length > 1) fail("impel remote follow: expected at most one run id");
612
+ const state = syncTaskStatus(readRunState(resolveRunId(positionals[0])));
613
+ if (state.status !== "running") fail(`impel remote follow: run ${state.runId} is ${state.status}, not running`);
614
+ if (!state.execution) fail(`impel remote follow: run ${state.runId} has no headless execution`);
615
+ await followRemoteWorker(state);
616
+ }
617
+
459
618
  async function cmdDown(argv) {
460
619
  const { flags, positionals } = parseFlags(argv, {
461
620
  all: { type: "boolean" },
@@ -472,40 +631,9 @@ async function cmdDown(argv) {
472
631
  : [readRunState(resolveRunId(positionals[0], { includeStopped: true }))];
473
632
  const config = loadConfig();
474
633
  for (let state of states) {
475
- const context = awsContext(state);
476
- if (state.status !== "stopped" && state.aws?.taskArn) {
477
- try { stopTask(context, state); } catch (error) {
478
- const current = describeTask(context, state);
479
- if (current.lastStatus !== "STOPPED") throw error;
480
- }
481
- }
482
- let revokeError = null;
483
- if (!state.credential?.revokedAt) {
484
- if (!config?.pat) {
485
- revokeError = new Error("no local Impel credential is available to revoke the remote PAT");
486
- } else {
487
- try { state = await revokeRunCredential(config, state); } catch (error) { revokeError = error; }
488
- }
489
- }
490
- removeSshAlias(state.runId);
491
- removeRunSecrets(state.runId);
492
- state = writeRunState({
493
- ...state,
494
- status: "stopped",
495
- stoppedAt: state.stoppedAt || new Date().toISOString(),
496
- ...(revokeError ? {
497
- credential: {
498
- ...state.credential,
499
- revokeError: redactSecretText(revokeError?.message || revokeError),
500
- },
501
- } : {}),
502
- });
503
- if (revokeError) {
504
- console.warn(`Stopped remote run ${state.runId} and removed its SSH alias, but credential revocation must be retried: ${redactSecretText(revokeError?.message || revokeError)}`);
505
- process.exitCode = 1;
506
- } else {
507
- console.log(`Stopped remote run ${state.runId}; revoked its credential and removed its SSH alias.`);
508
- }
634
+ const cleaned = await stopAndCleanRun(config, state);
635
+ state = cleaned.state;
636
+ printCleanupResult(state, cleaned.revokeError);
509
637
  }
510
638
  }
511
639
 
@@ -543,6 +671,8 @@ export async function cmdRemote(argv) {
543
671
  case "status": return await cmdStatus(rest);
544
672
  case "attach": return await cmdAttach(rest);
545
673
  case "dispatch": return await cmdDispatch(rest);
674
+ case "handoff": return await cmdHandoff(rest);
675
+ case "follow": return await cmdFollow(rest);
546
676
  case "down": return await cmdDown(rest);
547
677
  case "proxy": return cmdProxy(rest);
548
678
  default: fail(`impel remote: unknown subcommand ${JSON.stringify(action)}`);
@@ -40,6 +40,14 @@ function writePrivate(filePath, contents) {
40
40
  try { fs.chmodSync(filePath, 0o600); } catch { /* Best effort on Windows. */ }
41
41
  }
42
42
 
43
+ function localTarEnvironment() {
44
+ // macOS libarchive serializes extended attributes into AppleDouble `._*`
45
+ // entries unless copyfile metadata is explicitly disabled. Those synthetic
46
+ // files make a clean transferred repository appear dirty on Linux even when
47
+ // tar's --no-xattrs option is present.
48
+ return { ...process.env, COPYFILE_DISABLE: "1" };
49
+ }
50
+
43
51
  export function inspectRepository(requestedPath = process.cwd()) {
44
52
  const requested = path.resolve(requestedPath);
45
53
  const candidate = fs.realpathSync(requested);
@@ -98,7 +106,7 @@ export function createRepositoryTransfer(state, repository) {
98
106
  "--no-xattrs",
99
107
  "--null",
100
108
  "-T", paths.fileList,
101
- ], { cwd: repository.root });
109
+ ], { cwd: repository.root, env: localTarEnvironment() });
102
110
  return paths;
103
111
  }
104
112
 
@@ -119,6 +127,18 @@ function joinNullBuffer(entries) {
119
127
  return Buffer.concat(entries.flatMap((entry) => [entry, Buffer.from([0])]));
120
128
  }
121
129
 
130
+ function claudeProjectStorageKey(projectPath) {
131
+ return String(projectPath).replace(/[^A-Za-z0-9]/gu, "-");
132
+ }
133
+
134
+ export function checkpointRemoteRelativePath(provider, relativePath, remoteProjectPath) {
135
+ const normalized = String(relativePath).split(path.sep).join("/");
136
+ if (provider !== "claude") return normalized;
137
+ const segments = normalized.split("/");
138
+ if (segments[0] !== "projects" || segments.length < 3) return normalized;
139
+ return ["projects", claudeProjectStorageKey(remoteProjectPath), ...segments.slice(2)].join("/");
140
+ }
141
+
122
142
  export async function waitForSsh(state, timeoutSeconds = 120) {
123
143
  const deadline = Date.now() + timeoutSeconds * 1000;
124
144
  let lastError = "SSH did not accept a connection";
@@ -274,15 +294,46 @@ export function transferSessionCheckpoint(state, { provider, sessionId, tenantId
274
294
  }
275
295
  const checkpointList = path.join(runPaths(state.runId).root, `checkpoint-${provider}.list`);
276
296
  const checkpointArchive = path.join(runPaths(state.runId).root, `checkpoint-${provider}.tar`);
297
+ const mappings = files.map((relative) => ({
298
+ source: relative.split(path.sep).join("/"),
299
+ destination: checkpointRemoteRelativePath(provider, relative, state.repository.remoteProjectPath),
300
+ }));
277
301
  writePrivate(checkpointList, Buffer.from(`${files.join("\0")}\0`, "utf8"));
278
302
  try {
279
303
  runCapture(process.env.IMPEL_REMOTE_TAR_BIN || "tar", [
280
304
  "-cf", checkpointArchive, "--no-xattrs", "--null", "-T", checkpointList,
281
- ], { cwd: localRoot });
305
+ ], { cwd: localRoot, env: localTarEnvironment() });
282
306
  sshCapture(state, `mkdir -p ${remoteRoot}`);
283
307
  runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, `tar -xf - -C ${remoteRoot}`), {
284
308
  inputFile: checkpointArchive,
285
309
  });
310
+ if (provider === "claude") {
311
+ remoteNode(state, String.raw`
312
+ const fs = require("node:fs");
313
+ const path = require("node:path");
314
+ const payload = JSON.parse(fs.readFileSync(0, "utf8"));
315
+ const root = path.resolve(payload.root);
316
+ for (const mapping of payload.mappings) {
317
+ const source = path.resolve(root, mapping.source);
318
+ const destination = path.resolve(root, mapping.destination);
319
+ if (!source.startsWith(root + path.sep) || !destination.startsWith(root + path.sep)) {
320
+ throw new Error("unsafe checkpoint path");
321
+ }
322
+ if (source === destination) continue;
323
+ if (!fs.statSync(source, { throwIfNoEntry: false })?.isFile()) {
324
+ throw new Error("checkpoint source is missing");
325
+ }
326
+ if (fs.existsSync(destination)) throw new Error("checkpoint destination already exists");
327
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
328
+ fs.renameSync(source, destination);
329
+ let emptyParent = path.dirname(source);
330
+ while (emptyParent !== root && emptyParent.startsWith(root + path.sep)) {
331
+ try { fs.rmdirSync(emptyParent); } catch { break; }
332
+ emptyParent = path.dirname(emptyParent);
333
+ }
334
+ }
335
+ `, { root: remoteRoot, mappings });
336
+ }
286
337
  } finally {
287
338
  fs.rmSync(checkpointList, { force: true });
288
339
  fs.rmSync(checkpointArchive, { force: true });
@@ -65,6 +65,7 @@ export function impelCliInvocation(args = [], options = {}) {
65
65
  }
66
66
 
67
67
  export const IMPEL_MANAGED_MCP_ENV = brandedEnvironmentName("MANAGED_MCP");
68
+ export const IMPEL_TASKS_MCP_SERVER_NAME = `${RUNTIME_BRAND.cli.providerId}-tasks`;
68
69
 
69
70
  export function impelMcpInvocation(args = [], options = {}) {
70
71
  return {
@@ -73,3 +74,29 @@ export function impelMcpInvocation(args = [], options = {}) {
73
74
  env: { [IMPEL_MANAGED_MCP_ENV]: "1" },
74
75
  };
75
76
  }
77
+
78
+ /**
79
+ * The tenant-bound Tasks MCP process launched by managed Claude/Codex hosts.
80
+ * The PAT remains in Impel's private config and is read by the child process;
81
+ * it must never be baked into this invocation or a vendor profile.
82
+ */
83
+ export function impelTasksMcpInvocation(tenantId, options = {}) {
84
+ if (typeof tenantId !== "string" || !tenantId.trim()) {
85
+ throw new Error("a tenant is required for the managed Tasks MCP invocation");
86
+ }
87
+ return impelMcpInvocation(["--target", "tasks", "--tenant", tenantId], options);
88
+ }
89
+
90
+ /** Recognize only the marker-bearing Tasks invocation that impel-cli owns. */
91
+ export function isImpelTasksMcpInvocation(value) {
92
+ if (!value || typeof value !== "object" || !Array.isArray(value.args)) return false;
93
+ const suffix = value.args.slice(-5);
94
+ return value.type === "stdio"
95
+ && value.env?.[IMPEL_MANAGED_MCP_ENV] === "1"
96
+ && suffix[0] === "mcp"
97
+ && suffix[1] === "--target"
98
+ && suffix[2] === "tasks"
99
+ && suffix[3] === "--tenant"
100
+ && typeof suffix[4] === "string"
101
+ && suffix[4].length > 0;
102
+ }
@@ -1,64 +0,0 @@
1
- export const VERBATIM_RELAY_OPT_IN_MARKER = "verbatimRelay enabled";
2
-
3
- export const VERBATIM_SPAWN_REQUIREMENT = 'fork_turns="none"';
4
-
5
- export const VERBATIM_FINAL_TEXT_CONSTRAINTS =
6
- "no preface, rewriting, Markdown changes, or independent synthesis";
7
-
8
- export function usesVerbatimRelay(agent) {
9
- return agent?.verbatimRelay === true;
10
- }
11
-
12
- export function parentVerbatimRelayAppendix() {
13
- return (
14
- `When an explicit custom agent's catalog-derived description declares ${VERBATIM_RELAY_OPT_IN_MARKER}, ` +
15
- `spawn it with ${VERBATIM_SPAWN_REQUIREMENT} and relay its finalText verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}; ` +
16
- "preserve Sources sections and citations exactly. " +
17
- "Custom agents without that declaration keep the default delegation behavior."
18
- );
19
- }
20
-
21
- export function customAgentVerbatimDescriptionLead() {
22
- return (
23
- `Explicit custom agent with ${VERBATIM_RELAY_OPT_IN_MARKER}: ` +
24
- `callers must use ${VERBATIM_SPAWN_REQUIREMENT} and relay its result verbatim`
25
- );
26
- }
27
-
28
- export function adapterCallerSpawnGuidance(clientLabel) {
29
- return (
30
- `Callers must spawn this explicit custom ${clientLabel} agent with ${VERBATIM_SPAWN_REQUIREMENT} ` +
31
- "and must relay your result verbatim; this is caller guidance and cannot enforce host spawn behavior."
32
- );
33
- }
34
-
35
- export function adapterHardCompletionGuidance() {
36
- return (
37
- `After the orchestration completes, return its single text output verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
38
- "A successful output is result.finalText exactly. A failure output preserves the durable runId, output, and error."
39
- );
40
- }
41
-
42
- export function adapterSoftCompletionGuidance() {
43
- return (
44
- "After the orchestration completes, return its single text output as the answer. " +
45
- "A successful output is result.finalText. A failure output preserves the durable runId, output, and error. " +
46
- "Never invent or independently synthesize a replacement result."
47
- );
48
- }
49
-
50
- export function claudeHardCompletionGuidance() {
51
- return (
52
- `When it succeeds, return result.finalText exactly with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
53
- "When it fails, return the durable runId, preserved output, and error. " +
54
- "Never invent or independently synthesize a replacement result."
55
- );
56
- }
57
-
58
- export function claudeSoftCompletionGuidance() {
59
- return (
60
- "When it succeeds, return result.finalText faithfully as the answer. " +
61
- "When it fails, return the durable runId, preserved output, and error. " +
62
- "Never invent or independently synthesize a replacement result."
63
- );
64
- }