impel-cli 0.20.2 → 0.20.4

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
@@ -182,89 +182,82 @@ impel codex exec "review this repository"
182
182
 
183
183
  ## Remote Fargate sessions
184
184
 
185
- Start a disposable AWS Fargate runner for the current Git repository:
185
+ Remote operations use the tenant broker. The laptop sends its PAT only to that
186
+ broker; the runner receives a separate run-bound credential. Production modes
187
+ do not call AWS and do not require an AWS profile.
186
188
 
187
- ```sh
188
- impel remote up --provider codex
189
- ```
190
-
191
- The command creates a one-day, provider-scoped disposable PAT, starts the
192
- TTL-bounded task, installs a concrete SSH alias, transfers Git history plus the
193
- tracked and nonignored working tree, restores recognized Node/Python lockfile
194
- dependencies, and uploads the disposable credential only through encrypted
195
- SSH. The user's stored PAT, ignored files, SSH agent, Keychain, and environment
196
- are not copied. Select variables explicitly when they are genuinely required:
197
-
198
- ```sh
199
- impel remote up --provider claude --env API_HOST,FEATURE_FLAG
200
- ```
201
-
202
- Use the remote session from a terminal:
189
+ The public modes are deliberately asymmetric:
203
190
 
204
191
  ```sh
205
- impel remote status
206
- impel remote attach --provider codex
207
- impel remote attach --provider claude
208
- ```
192
+ # Same Codex Desktop chat, through the documented SSH run-location handoff.
193
+ impel remote handoff . --provider codex --mode native --session <sessions-uuid>
209
194
 
210
- For Codex Desktop on macOS, open the native connection flow:
195
+ # New provider-native branch from a closed exact checkpoint.
196
+ impel remote dispatch . --provider codex --mode fork --session <sessions-uuid> \
197
+ --checkpoint <archive> --checkpoint-manifest <manifest>
198
+ impel remote dispatch . --provider claude --mode fork --session <sessions-uuid> \
199
+ --checkpoint <archive> --checkpoint-manifest <manifest>
211
200
 
212
- ```sh
213
- impel remote attach --provider codex --desktop
201
+ # Explicit non-exact context/workspace transfer.
202
+ impel remote dispatch . --provider claude --mode portable --session <sessions-uuid>
214
203
  ```
215
204
 
216
- Save `/workspace/repo` as the remote project. In an existing Codex chat, use
217
- the footer's run-location picker and choose the generated SSH host. Codex then
218
- uses its supported SSH app-server transport to move the chat and Git state and
219
- stream prompts, approvals, tools, diffs, and output in the local app.
220
-
221
- Claude Desktop can open a new native SSH session on the same alias. Claude does
222
- not expose an arbitrary-host equivalent of Codex's exact chat handoff. To move
223
- an existing Impel Claude/Codex CLI session, checkpoint it and create a remote
224
- continuation or fork:
205
+ `--session` is the required lowercase UUID of the Impel Sessions binding for
206
+ the chat. It is required for native, fork, and portable broker creates. It is
207
+ not the provider-native Codex thread ID or Claude session ID; fork checkpoints
208
+ carry that separate provider identity in their closed manifest. The CLI never
209
+ invents a Sessions binding or substitutes a provider thread ID for one.
210
+
211
+ Every request negotiates the broker's versioned capability response before a
212
+ run is created. Unsupported combinations fail locally or before provisioning;
213
+ the CLI never changes native/fork to portable automatically. Gateway-backed
214
+ Claude has no native Anthropic Remote Control path. Codex App Server supports
215
+ fork/portable, not a Desktop native-handoff claim.
216
+
217
+ For a native request started in Codex CLI, run `/app` first so Desktop opens the
218
+ same saved chat. The CLI waits for explicit confirmation and then negotiates
219
+ the Desktop capability. It writes a run-specific SSH host whose `ProxyCommand`
220
+ asks the broker for a fresh single-use connection lease on every OpenSSH
221
+ connection. The private Ed25519 key remains local; only its public key is sent
222
+ to the broker. No AWS credentials or initiating PAT enter the task.
223
+
224
+ Codex Desktop and the tunnel must remain awake and online for ChatGPT Remote.
225
+ For laptop-independent control, use the App Server fork/portable path and the
226
+ Impel viewer. Once the broker reports the run as `running`, create a fresh
227
+ one-time browser link without persisting its exchange secret locally:
225
228
 
226
229
  ```sh
227
- impel remote dispatch --provider claude --session <session-id> --fork
228
- impel remote dispatch --provider codex --session <session-id> --fork
230
+ impel remote viewer <run-id>
229
231
  ```
230
232
 
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
-
255
- Remote vendor commands run in dangerous/bypass mode as the unprivileged
256
- `agent` user; the disposable Fargate task is the isolation boundary. Existing
257
- Impel lifecycle hooks continue mirroring remote transcripts to
258
- `impel-sessions`. Stop the task and revoke its credential when finished:
233
+ The exchange secret is carried only in the URL fragment and the viewer removes
234
+ it immediately after exchanging it for a run-bound HttpOnly cookie. Handback is
235
+ also explicit: select **This computer** in Desktop,
236
+ wait for the remote child to exit, then run:
259
237
 
260
238
  ```sh
261
- impel remote down --yes
239
+ impel remote handback <run-id> --yes
262
240
  ```
263
241
 
264
- The current lifecycle client requires macOS or Linux, OpenSSH, the AWS CLI, the
265
- AWS Session Manager plugin, and permission to use the deployed
266
- `impel-remote-dev` stack. Use `impel remote help` for region, profile, TTL,
267
- dependency, and setup overrides.
242
+ The broker stops and revokes only after both the provider/SSH close signal and
243
+ the confirmation. A lost tunnel receives reconnect grace and is not treated as
244
+ successful handback. `--abandon` requests forced safe cleanup; it never starts
245
+ a local writer.
246
+
247
+ Brokered fork/portable runs capture an immutable workspace artifact that keeps
248
+ Git history and the exact staged, unstaged, deleted, untracked, executable,
249
+ rename, binary, and intent-to-add state distinct. Conflict, submodule, LFS,
250
+ unsafe-link, case-collision, and size-limit cases fail before upload. Exact
251
+ provider checkpoints must be closed, versioned, quiesced, and hash-verified.
252
+ After create assigns the run ID, the CLI rehashes the isolated staging archive,
253
+ adds only a canonical run-bound `manifest.json`, and uploads the closed result;
254
+ mirrored open session files are never labeled exact.
255
+
256
+ The former one-shot worker remains only as a short-lived rollback through
257
+ `--mode portable-legacy` plus `IMPEL_REMOTE_ENABLE_PORTABLE_LEGACY=1`. It is
258
+ never selected implicitly and is scheduled for removal on 2026-10-31 together
259
+ with direct AWS, PAT upload, and SSH file-tail behavior. See
260
+ [RELEASE_NOTES.md](RELEASE_NOTES.md).
268
261
 
269
262
  Changing the selected tenant does not install, remove, or rewrite any other
270
263
  tenant. It changes only the default used by CLI launches and selected-tenant
@@ -0,0 +1,13 @@
1
+ # Release notes
2
+
3
+ ## Native remote migration
4
+
5
+ The former `impel remote handoff` implementation started a one-shot headless
6
+ continuation and did not transfer a live provider session. The unqualified
7
+ spelling no longer invokes that worker.
8
+
9
+ Until **2026-10-31**, operators may invoke the rollback implementation only by
10
+ setting `IMPEL_REMOTE_ENABLE_PORTABLE_LEGACY=1` and passing
11
+ `--mode portable-legacy`. It is never selected implicitly. The flag, worker,
12
+ direct AWS/SSM path, PAT upload, SSH file tail, and its invocation counter are
13
+ scheduled for removal together after the native/broker acceptance gates pass.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "./extension": "./src/extension/index.js"
12
12
  },
13
13
  "files": [
14
+ "RELEASE_NOTES.md",
14
15
  "bin",
15
16
  "docs/experimental-managed-cursor.md",
16
17
  "src",
package/src/agents.js CHANGED
@@ -19,7 +19,23 @@ import {
19
19
  IMPEL_NATIVE_AGENT_MCP_TARGET,
20
20
  impelNativeAgentMcpInvocation,
21
21
  } from "./selfInvocation.js";
22
+ import {
23
+ adapterAnswerFaithfulCompletionGuidance,
24
+ adapterAnswerVerbatimCompletionGuidance,
25
+ claudeAnswerFaithfulCompletionGuidance,
26
+ claudeAnswerVerbatimCompletionGuidance,
27
+ usesDirectAnswer,
28
+ } from "./directAnswer.js";
22
29
  import { normalizeTenantId } from "./tenants.js";
30
+ import {
31
+ adapterCallerSpawnGuidance,
32
+ adapterFaithfulCompletionGuidance,
33
+ adapterVerbatimCompletionGuidance,
34
+ claudeFaithfulCompletionGuidance,
35
+ claudeVerbatimCompletionGuidance,
36
+ customAgentVerbatimDescriptionLead,
37
+ usesVerbatimRelay,
38
+ } from "./verbatimRelay.js";
23
39
  import { renameWithWindowsRetry } from "./windowsFs.js";
24
40
 
25
41
  export const AGENT_SYNC_TTL_MS = 6 * 60 * 60 * 1000;
@@ -28,11 +44,13 @@ export const MANAGED_AGENT_MANIFEST = ".manifest.json";
28
44
  export const NATIVE_AGENT_LIST_TOOL = "impel_specialists-list_native_agents";
29
45
  export const NATIVE_AGENT_START_TOOL = "impel_specialists-start_native_agent_run";
30
46
  export const NATIVE_AGENT_READ_TOOL = "impel_specialists-read_native_agent_run";
47
+ export const NATIVE_AGENT_UPSTREAM_ANSWER_TOOL = "impel_specialists-answer_native_agent";
48
+ export const NATIVE_AGENT_ANSWER_TOOL = "answer_native_agent";
31
49
  export const NATIVE_AGENT_RUN_TOOL = "run_native_agent";
32
50
  export const NATIVE_AGENT_RESUME_TOOL = "resume_native_agent_run";
33
51
  export const NATIVE_AGENT_RECOVER_TOOL = "recover_native_agent_runs";
34
52
  export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
35
- export const MANAGED_AGENT_MANIFEST_VERSION = 6;
53
+ export const MANAGED_AGENT_MANIFEST_VERSION = 7;
36
54
 
37
55
  const NATIVE_AGENT_TOOL_NAMES = [
38
56
  NATIVE_AGENT_RUN_TOOL,
@@ -40,6 +58,9 @@ const NATIVE_AGENT_TOOL_NAMES = [
40
58
  NATIVE_AGENT_RECOVER_TOOL,
41
59
  ];
42
60
  const NATIVE_AGENT_RECOVERY_TOOL_NAMES = [NATIVE_AGENT_RESUME_TOOL, NATIVE_AGENT_RECOVER_TOOL];
61
+ function nativeAgentToolNames(agent) {
62
+ return usesDirectAnswer(agent) ? [NATIVE_AGENT_ANSWER_TOOL] : NATIVE_AGENT_TOOL_NAMES;
63
+ }
43
64
  const MAX_RETIRED_AGENT_BINDINGS = 50;
44
65
  const MAX_NATIVE_AGENT_STATE_BYTES = 512 * 1024;
45
66
  const TERMINAL_NATIVE_AGENT_STATUSES = new Set(["succeeded", "failed", "cancelled", "canceled"]);
@@ -140,6 +161,12 @@ export function normalizeNativeAgentCatalog(payload, expectedTenantId) {
140
161
  throw new Error(`native-agent catalog returned duplicate binding ${binding}`);
141
162
  }
142
163
  seenBindings.add(binding);
164
+ if (agent.verbatimRelay !== undefined && typeof agent.verbatimRelay !== "boolean") {
165
+ throw new Error("native-agent catalog returned an invalid verbatimRelay");
166
+ }
167
+ if (agent.directAnswer !== undefined && typeof agent.directAnswer !== "boolean") {
168
+ throw new Error("native-agent catalog returned an invalid directAnswer");
169
+ }
143
170
  return {
144
171
  agentId,
145
172
  title: boundedString(agent.title, "title", { max: 160 }),
@@ -150,6 +177,8 @@ export function normalizeNativeAgentCatalog(payload, expectedTenantId) {
150
177
  exclusions: stringList(agent.exclusions, "exclusions"),
151
178
  requiredContext: stringList(agent.requiredContext, "requiredContext"),
152
179
  sideEffects: enumString(agent.sideEffects, "sideEffects", ["read-only", "writes"]),
180
+ ...(agent.verbatimRelay === true ? { verbatimRelay: true } : {}),
181
+ ...(agent.directAnswer === true ? { directAnswer: true } : {}),
153
182
  };
154
183
  });
155
184
  return { orgId: tenantId, agents };
@@ -559,14 +588,35 @@ function claudeAdapterInstructions(tenantId, agent) {
559
588
  const sideEffectInstruction = agent.sideEffects === "writes"
560
589
  ? `The synchronized policy 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. If the agent was chosen automatically or the selection is ambiguous, do not start it and ask the user to select it explicitly.`
561
590
  : `The synchronized policy declares that this agent is read-only.`;
591
+ const callerGuidance = usesVerbatimRelay(agent)
592
+ ? adapterCallerSpawnGuidance("Claude")
593
+ : null;
594
+ if (usesDirectAnswer(agent)) {
595
+ const completionGuidance = usesVerbatimRelay(agent)
596
+ ? claudeAnswerVerbatimCompletionGuidance()
597
+ : claudeAnswerFaithfulCompletionGuidance();
598
+ return [
599
+ `You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
600
+ ...(callerGuidance ? [callerGuidance] : []),
601
+ `Do not perform the assigned task yourself and do not delegate to any other agent.`,
602
+ `Confirm that the request fits the synchronized capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)}.${contextRequirement}`,
603
+ sideEffectInstruction,
604
+ `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the complete assigned task, optional context set to one string containing all supplied context, and contextKeys naming the fields present in that string.`,
605
+ completionGuidance,
606
+ ].join(" ");
607
+ }
608
+ const completionGuidance = usesVerbatimRelay(agent)
609
+ ? claudeVerbatimCompletionGuidance()
610
+ : claudeFaithfulCompletionGuidance();
562
611
  return [
563
612
  `You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
613
+ ...(callerGuidance ? [callerGuidance] : []),
564
614
  `Do not perform the assigned task yourself and do not delegate to any other agent.`,
565
615
  `Confirm that the request fits the synchronized capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)}.${contextRequirement}`,
566
616
  sideEffectInstruction,
567
617
  `Call ${nativeToolName(NATIVE_AGENT_RUN_TOOL)} exactly once with task set to the complete assigned task, optional context set to one string containing all supplied context, and contextKeys naming the fields present in that string.`,
568
618
  `If that call returns an ${JSON.stringify("impel.native-agent-run.v1")} handle, call ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} with exactly that same handle until the transport returns a terminal result. If the call is cancelled, the connection closes, or its result is unknown before you receive a handle, call ${nativeToolName(NATIVE_AGENT_RECOVER_TOOL)} with {} and resume the newest returned pending handle; if multiple handles cannot be safely associated with this request, report them instead of choosing. Never call run_native_agent again for this request, and never restart, nudge, replace, or independently poll the upstream run.`,
569
- `When it succeeds, return finalText byte-for-byte as the answer with no preface or rewriting. When it fails, return the preserved runId, output, and error. Never invent or independently synthesize a replacement result.`,
619
+ completionGuidance,
570
620
  ].join(" ");
571
621
  }
572
622
 
@@ -581,15 +631,35 @@ function codexAdapterInstructions(tenantId, agent) {
581
631
  const sideEffectInstruction = agent.sideEffects === "writes"
582
632
  ? `The synchronized policy 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. If the agent was chosen automatically or the selection is ambiguous, ask the user to select this exact agent explicitly.`
583
633
  : `The synchronized policy declares that this agent is read-only.`;
634
+ const callerGuidance = usesVerbatimRelay(agent)
635
+ ? adapterCallerSpawnGuidance("Codex")
636
+ : null;
637
+ if (usesDirectAnswer(agent)) {
638
+ const completionGuidance = usesVerbatimRelay(agent)
639
+ ? adapterAnswerVerbatimCompletionGuidance()
640
+ : adapterAnswerFaithfulCompletionGuidance();
641
+ return [
642
+ `You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
643
+ ...(callerGuidance ? [callerGuidance] : []),
644
+ `Do not perform the assigned task yourself, do not delegate to any other agent, and do not independently synthesize the result.`,
645
+ `Confirm that the assigned request fits the cataloged capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)} before answering.${contextRequirement}`,
646
+ sideEffectInstruction,
647
+ `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the complete assigned task, optional context set to one string containing all supplied context, and contextKeys naming the fields present in that string.`,
648
+ completionGuidance,
649
+ ].join("\n\n");
650
+ }
651
+ const completionGuidance = usesVerbatimRelay(agent)
652
+ ? adapterVerbatimCompletionGuidance()
653
+ : adapterFaithfulCompletionGuidance();
584
654
  return [
585
655
  `You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
586
- `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.`,
656
+ ...(callerGuidance ? [callerGuidance] : []),
587
657
  `Do not perform the assigned task yourself, do not delegate to any other agent, and do not independently synthesize or rewrite the result.`,
588
658
  `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}`,
589
659
  sideEffectInstruction,
590
660
  `Call ${nativeToolName(NATIVE_AGENT_RUN_TOOL)} exactly once with the complete task, optional context string, and supplied context keys. If it returns an ${JSON.stringify("impel.native-agent-run.v1")} handle, call ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} with exactly that same handle until the transport returns a terminal result. If the call is cancelled, the connection closes, or its result is unknown before you receive a handle, call ${nativeToolName(NATIVE_AGENT_RECOVER_TOOL)} with {} and resume the newest returned pending handle; if multiple handles cannot be safely associated with this request, report them instead of choosing. Never call run_native_agent again for this request, and never restart, nudge, replace, or independently poll the upstream run.`,
591
661
  `While a remote wait is healthy, the parent must not message, follow up with, or interrupt this adapter.`,
592
- `Return successful finalText byte-for-byte with no preface, rewriting, Markdown changes, or independent synthesis. A failure preserves the durable runId, output, and error.`,
662
+ completionGuidance,
593
663
  ].join("\n\n");
594
664
  }
595
665
 
@@ -602,11 +672,21 @@ function retiredAdapterInstructions(tenantId, agent) {
602
672
  ].join(" ");
603
673
  }
604
674
 
675
+ function customAgentDescription(tenantId, agent) {
676
+ const sideEffects = agent.sideEffects === "writes"
677
+ ? " (may write user or workspace data)"
678
+ : " (read-only)";
679
+ return (usesVerbatimRelay(agent)
680
+ ? `${customAgentVerbatimDescriptionLead()}. Runs ${agent.title} for Impel tenant ${tenantId}${sideEffects}: ${agent.description}`
681
+ : `Explicitly runs ${agent.title} for Impel tenant ${tenantId}${sideEffects}: ${agent.description}`
682
+ ).slice(0, 900);
683
+ }
684
+
605
685
  function renderClaudeAgent({ tenantId, agent, name, invocation, recoveryOnly = false }) {
606
686
  const description = recoveryOnly
607
687
  ? `Recovery-only access to pending runs for retired Impel binding ${agent.agentId} in tenant ${tenantId}.`
608
- : `Explicitly runs ${agent.title} for Impel tenant ${tenantId}${agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)"}: ${agent.description}`.slice(0, 900);
609
- const toolNames = recoveryOnly ? NATIVE_AGENT_RECOVERY_TOOL_NAMES : NATIVE_AGENT_TOOL_NAMES;
688
+ : customAgentDescription(tenantId, agent);
689
+ const toolNames = recoveryOnly ? NATIVE_AGENT_RECOVERY_TOOL_NAMES : nativeAgentToolNames(agent);
610
690
  const lines = [
611
691
  "---",
612
692
  `name: ${JSON.stringify(name)}`,
@@ -633,8 +713,8 @@ function renderClaudeAgent({ tenantId, agent, name, invocation, recoveryOnly = f
633
713
  function renderCodexAgent({ tenantId, agent, name, invocation, recoveryOnly = false }) {
634
714
  const description = recoveryOnly
635
715
  ? `Recovery-only custom agent for pending runs from retired Impel binding ${agent.agentId} in tenant ${tenantId}.`
636
- : `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);
637
- const toolNames = recoveryOnly ? NATIVE_AGENT_RECOVERY_TOOL_NAMES : NATIVE_AGENT_TOOL_NAMES;
716
+ : customAgentDescription(tenantId, agent);
717
+ const toolNames = recoveryOnly ? NATIVE_AGENT_RECOVERY_TOOL_NAMES : nativeAgentToolNames(agent);
638
718
  const envEntries = Object.entries(invocation.env || {})
639
719
  .map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`)
640
720
  .join(", ");
@@ -659,7 +739,7 @@ function renderCodexAgent({ tenantId, agent, name, invocation, recoveryOnly = fa
659
739
 
660
740
  function boundNativeAgentInvocation(tenantId, agent, invocation, {
661
741
  policyFingerprint = nativeAgentPolicyFingerprint(agent),
662
- recoveryOnly = false,
742
+ mode = usesDirectAnswer(agent) ? "answer" : "durable",
663
743
  } = {}) {
664
744
  if (!invocation) {
665
745
  return impelNativeAgentMcpInvocation({
@@ -667,7 +747,7 @@ function boundNativeAgentInvocation(tenantId, agent, invocation, {
667
747
  agentId: agent.agentId,
668
748
  scopeParam: agent.scopeParam,
669
749
  policyFingerprint,
670
- recoveryOnly,
750
+ mode,
671
751
  });
672
752
  }
673
753
  const mcpIndex = invocation.args?.lastIndexOf("mcp") ?? -1;
@@ -681,7 +761,8 @@ function boundNativeAgentInvocation(tenantId, agent, invocation, {
681
761
  "--agent-id", agent.agentId,
682
762
  "--scope-param", agent.scopeParam,
683
763
  "--policy-fingerprint", policyFingerprint,
684
- ...(recoveryOnly ? ["--recovery-only"] : []),
764
+ ...(mode === "recovery" ? ["--recovery-only"] : []),
765
+ ...(mode === "answer" ? ["--answer-only"] : []),
685
766
  ],
686
767
  };
687
768
  }
@@ -753,7 +834,7 @@ function renderRetiredManagedAgents(client, tenantId, bindings, active, invocati
753
834
  };
754
835
  const boundInvocation = boundNativeAgentInvocation(tenantId, agent, invocation, {
755
836
  policyFingerprint: binding.policyFingerprint,
756
- recoveryOnly: true,
837
+ mode: "recovery",
757
838
  });
758
839
  const contents = client === "claude"
759
840
  ? renderClaudeAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true })
@@ -841,7 +922,7 @@ export function syncAgentProfile({
841
922
  throw new Error("generated native-agent destinations are not unique");
842
923
  }
843
924
  const priorFiles = new Set(prior?.files || []);
844
- const priorUsesDiscoveryRoot = [2, 3, 4, 5, MANAGED_AGENT_MANIFEST_VERSION].includes(prior?.version);
925
+ const priorUsesDiscoveryRoot = [2, 3, 4, 5, 6, MANAGED_AGENT_MANIFEST_VERSION].includes(prior?.version);
845
926
 
846
927
  // Native clients discover standalone definitions directly under `agents/`.
847
928
  // Preflight every destination before writing so an unmanaged file with the
package/src/apps.js CHANGED
@@ -22,6 +22,7 @@ import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHook
22
22
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
23
23
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
24
24
  import { brandedEnvironmentName, RUNTIME_BRAND } from "./runtimeBrand.js";
25
+ import { IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS } from "./commands/launch.js";
25
26
 
26
27
  export const CLAUDE_CONFIG_ID = "1ced0000-0000-4000-8000-000000000001";
27
28
  const CHATGPT_CONFIG_START = `# >>> ${RUNTIME_BRAND.cli.command} app managed gateway >>>`;
@@ -276,7 +277,9 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
276
277
  // and ChatGPT/Codex profiles without changing the specialist MCP server.
277
278
  // 28: bind generated native-agent profiles to the durable local composite MCP
278
279
  // transport and remove model-controlled upstream start/read polling.
279
- export const CURRENT_CONFIG_VERSION = 28;
280
+ // 29: add marker-aware custom-agent verbatim relay guidance to ChatGPT and
281
+ // expose direct-answer native agents through their one-shot local MCP binding.
282
+ export const CURRENT_CONFIG_VERSION = 29;
280
283
 
281
284
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
282
285
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -1335,6 +1338,14 @@ function writeChatGPTConfig(
1335
1338
  }
1336
1339
  }
1337
1340
 
1341
+ const unmanagedToml = stripManagedChatGPTToml(currentToml);
1342
+ const userDeveloperInstructions = readTopLevelTomlString(unmanagedToml, "developer_instructions");
1343
+ const developerInstructions = userDeveloperInstructions
1344
+ ? `${userDeveloperInstructions}\n\n${IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS}`
1345
+ : IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS;
1346
+ const preservedToml = userDeveloperInstructions
1347
+ ? removeTopLevelTomlString(unmanagedToml, "developer_instructions")
1348
+ : unmanagedToml;
1338
1349
  const managedToml = [
1339
1350
  CHATGPT_CONFIG_START,
1340
1351
  `# Managed by \`${RUNTIME_BRAND.cli.command} app update\`. This profile is isolated from ~/.codex.`,
@@ -1347,6 +1358,9 @@ function writeChatGPTConfig(
1347
1358
  `model_catalog_json = ${tomlString(paths.chatgpt.catalog)}`,
1348
1359
  ...(selectedEffort ? [`model_reasoning_effort = ${tomlString(selectedEffort)}`] : []),
1349
1360
  ...(selectedTier ? [`service_tier = ${tomlString(selectedTier)}`] : []),
1361
+ ...(RUNTIME_BRAND.features.agents
1362
+ ? [`developer_instructions = ${tomlString(developerInstructions)}`]
1363
+ : []),
1350
1364
  "",
1351
1365
  // The built-in ChatGPT provider derives its inference endpoint from
1352
1366
  // chatgpt.com even when chatgpt_base_url points at the gateway. Keep the
@@ -1383,7 +1397,7 @@ function writeChatGPTConfig(
1383
1397
  ] : []),
1384
1398
  CHATGPT_CONFIG_END,
1385
1399
  ].join("\n");
1386
- const mergedToml = mergeManagedChatGPTToml(currentToml, managedToml);
1400
+ const mergedToml = mergeManagedChatGPTToml(preservedToml, managedToml);
1387
1401
  writeAtomic(configPath, hardenManagedCodexToml(mergedToml, configPath), 0o600);
1388
1402
  secureManagedCodexHome(paths.chatgpt.codexHome);
1389
1403
  }
@@ -1605,6 +1619,16 @@ function readTopLevelTomlString(toml, key) {
1605
1619
  }
1606
1620
  }
1607
1621
 
1622
+ function removeTopLevelTomlString(toml, key) {
1623
+ const tableIndex = toml.search(/^\s*\[/mu);
1624
+ const topLevelEnd = tableIndex === -1 ? toml.length : tableIndex;
1625
+ const topLevel = toml.slice(0, topLevelEnd).replace(
1626
+ new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*"(?:[^"\\\\]|\\\\.)*"\\s*\\r?\\n?`, "mu"),
1627
+ "",
1628
+ );
1629
+ return topLevel + toml.slice(topLevelEnd);
1630
+ }
1631
+
1608
1632
  /**
1609
1633
  * Rewrite one tenant's app token helper with the current node/CLI paths.
1610
1634
  * Cheap and Impel-owned, so install recovery can use it to heal helpers whose
package/src/cli.js CHANGED
@@ -35,7 +35,8 @@ Get started:
35
35
  Work:
36
36
  impel claude [args...] Launch Claude Code with an isolated Impel profile
37
37
  impel codex [args...] Launch Codex with an isolated Impel profile
38
- impel remote up|status|attach|down Run a local repository on AWS Fargate
38
+ impel remote handoff|dispatch|handback Move or control provider-native sessions remotely
39
+ impel remote status|viewer|proxy Inspect, control, or connect to a remote run
39
40
  impel tenant list List accessible organizations
40
41
  impel tenant current Show the CLI's current organization
41
42
  impel tenant use <org> Select the organization for CLI launches
@@ -15,6 +15,7 @@ import {
15
15
  saveConfig,
16
16
  } from "../config.js";
17
17
  import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.js";
18
+ import { parentVerbatimRelayAppendix } from "../verbatimRelay.js";
18
19
  import { withGitEnvironment } from "../skills.js";
19
20
  import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
20
21
  import { maybePrintUpdateNotice } from "../updates.js";
@@ -49,10 +50,18 @@ export {
49
50
 
50
51
  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.`;
51
52
 
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
+
52
58
  export const IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS =
53
59
  `${IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS} ` +
54
60
  `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.`;
55
61
 
62
+ export const IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS =
63
+ `${IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS} ${IMPEL_CUSTOM_AGENT_VERBATIM_RELAY_APPENDIX}`;
64
+
56
65
  const IMPEL_CODEX_RUNTIME_OVERRIDES = [
57
66
  // Codex models may select code mode even when the standalone
58
67
  // `codex-code-mode-host` companion is not present in the vendor install.
@@ -64,14 +73,14 @@ const IMPEL_CODEX_RUNTIME_OVERRIDES = [
64
73
  export function impelLaunchArguments(tool, argv) {
65
74
  if (!RUNTIME_BRAND.features.agents) return [...argv];
66
75
  if (tool === "claude") {
67
- return ["--append-system-prompt", IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS, ...argv];
76
+ return ["--append-system-prompt", IMPEL_CLAUDE_PARENT_DELEGATION_INSTRUCTIONS, ...argv];
68
77
  }
69
78
  if (tool === "codex") {
70
79
  // `-c` is a Codex global option, so it must precede subcommands such as
71
80
  // `exec`, `resume`, and `mcp`. JSON strings are valid TOML basic strings.
72
81
  return [
73
82
  "-c",
74
- `developer_instructions=${JSON.stringify(IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS)}`,
83
+ `developer_instructions=${JSON.stringify(IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS)}`,
75
84
  ...IMPEL_CODEX_RUNTIME_OVERRIDES.flatMap((override) => ["-c", override]),
76
85
  ...argv,
77
86
  ];
@@ -9,6 +9,7 @@ import {
9
9
  } from "../config.js";
10
10
  import { parseFlags } from "../args.js";
11
11
  import {
12
+ NATIVE_AGENT_ANSWER_TOOL,
12
13
  NATIVE_AGENT_RECOVER_TOOL,
13
14
  NATIVE_AGENT_RESUME_TOOL,
14
15
  NATIVE_AGENT_RUN_TOOL,
@@ -193,8 +194,9 @@ export function runNativeAgentMcpServer({
193
194
  transport,
194
195
  input = process.stdin,
195
196
  output = process.stdout,
196
- recoveryOnly = false,
197
+ mode = "durable",
197
198
  }) {
199
+ if (!["durable", "recovery", "answer"].includes(mode)) throw new Error("invalid native-agent MCP mode");
198
200
  const lines = readline.createInterface({ input, crlfDelay: Infinity });
199
201
  const active = new Map();
200
202
  const pending = new Set();
@@ -231,7 +233,9 @@ export function runNativeAgentMcpServer({
231
233
  return;
232
234
  }
233
235
  if (message.method === "tools/list") {
234
- write(nativeRpcResult(message.id, { tools: nativeAgentCompositeTools({ recoveryOnly }) }));
236
+ write(nativeRpcResult(message.id, {
237
+ tools: nativeAgentCompositeTools({ mode }),
238
+ }));
235
239
  return;
236
240
  }
237
241
  if (message.method !== "tools/call") {
@@ -249,12 +253,18 @@ export function runNativeAgentMcpServer({
249
253
  }));
250
254
  };
251
255
  let value;
252
- if (name === NATIVE_AGENT_RUN_TOOL) {
253
- if (recoveryOnly) throw new Error("retired native-agent bindings cannot start new runs");
256
+ if (name === NATIVE_AGENT_ANSWER_TOOL) {
257
+ if (mode !== "answer") throw new Error("this native-agent binding cannot answer directly");
258
+ value = await transport.answer(args, { signal: controller.signal });
259
+ } else if (name === NATIVE_AGENT_RUN_TOOL) {
260
+ if (mode === "recovery") throw new Error("retired native-agent bindings cannot start new runs");
261
+ if (mode === "answer") throw new Error("direct-answer native-agent bindings cannot start durable runs");
254
262
  value = await transport.run(args, { signal: controller.signal, onProgress });
255
263
  } else if (name === NATIVE_AGENT_RESUME_TOOL) {
264
+ if (mode === "answer") throw new Error("direct-answer native-agent bindings have no runs to resume");
256
265
  value = await transport.resume(args, { signal: controller.signal, onProgress });
257
266
  } else if (name === NATIVE_AGENT_RECOVER_TOOL) {
267
+ if (mode === "answer") throw new Error("direct-answer native-agent bindings have no runs to recover");
258
268
  value = transport.recover(args);
259
269
  } else {
260
270
  throw new Error("unsupported native-agent MCP tool");
@@ -268,7 +278,9 @@ export function runNativeAgentMcpServer({
268
278
  error: {
269
279
  code: cancelled ? -32800 : -32000,
270
280
  message: cancelled
271
- ? "Native-agent attachment cancelled; resume it with the same handle."
281
+ ? (mode === "answer"
282
+ ? "Direct-answer native-agent call cancelled; retry the answer request if needed."
283
+ : "Native-agent attachment cancelled; resume it with the same handle.")
272
284
  : redactSecretText(error?.message || error),
273
285
  },
274
286
  }));
@@ -306,6 +318,7 @@ export async function cmdMcp(argv = []) {
306
318
  "scope-param": { type: "string" },
307
319
  "policy-fingerprint": { type: "string" },
308
320
  "recovery-only": { type: "boolean" },
321
+ "answer-only": { type: "boolean" },
309
322
  });
310
323
  const targetSpecified = Object.hasOwn(flags, "target");
311
324
  if (targetSpecified && ![TASKS_TARGET, IMPEL_NATIVE_AGENT_MCP_TARGET].includes(flags.target)) {
@@ -323,7 +336,10 @@ export async function cmdMcp(argv = []) {
323
336
  }
324
337
  }
325
338
  if (nativeAgentTarget) {
326
- const expectedFlags = ["target", "tenant", "agent-id", "scope-param", "policy-fingerprint", "recovery-only"];
339
+ const expectedFlags = [
340
+ "target", "tenant", "agent-id", "scope-param", "policy-fingerprint",
341
+ "recovery-only", "answer-only",
342
+ ];
327
343
  const unsupportedFlags = Object.keys(flags).filter((name) => !expectedFlags.includes(name));
328
344
  if (unsupportedFlags.length > 0 || positionals.length > 0) {
329
345
  throw new Error("unsupported native-agent MCP arguments");
@@ -333,10 +349,14 @@ export async function cmdMcp(argv = []) {
333
349
  throw new Error(`native-agent MCP requires a fixed --${flag} argument`);
334
350
  }
335
351
  }
352
+ if (flags["recovery-only"] === true && flags["answer-only"] === true) {
353
+ throw new Error("native-agent MCP cannot be both recovery-only and answer-only");
354
+ }
336
355
  } else if (!tasksTarget && (Object.hasOwn(flags, "agent-id")
337
356
  || Object.hasOwn(flags, "scope-param")
338
357
  || Object.hasOwn(flags, "policy-fingerprint")
339
- || Object.hasOwn(flags, "recovery-only"))) {
358
+ || Object.hasOwn(flags, "recovery-only")
359
+ || Object.hasOwn(flags, "answer-only"))) {
340
360
  throw new Error("native-agent binding flags require `--target native-agent`");
341
361
  }
342
362
  const config = loadConfig();
@@ -358,7 +378,9 @@ export async function cmdMcp(argv = []) {
358
378
  gatewayUrl,
359
379
  credential,
360
380
  }),
361
- recoveryOnly: flags["recovery-only"] === true,
381
+ mode: flags["recovery-only"] === true
382
+ ? "recovery"
383
+ : (flags["answer-only"] === true ? "answer" : "durable"),
362
384
  });
363
385
  }
364
386
  const endpoint = tasksTarget ? null : `${gatewayUrl}/mcp`;