impel-cli 0.19.3 → 0.20.0-beta.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 +55 -0
- package/package.json +6 -5
- package/src/agents.js +186 -11
- package/src/apps.js +102 -10
- package/src/cliProfiles.js +47 -8
- package/src/commands/launch.js +11 -2
- package/src/commands/mcp.js +201 -17
- package/src/commands/remote.js +166 -36
- package/src/directAnswer.js +59 -0
- package/src/selfInvocation.js +27 -0
- package/src/verbatimRelay.js +64 -0
package/src/cliProfiles.js
CHANGED
|
@@ -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 {
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/launch.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveDefaultGateway,
|
|
15
15
|
saveConfig,
|
|
16
16
|
} from "../config.js";
|
|
17
|
+
import { parentVerbatimRelayAppendix } from "../verbatimRelay.js";
|
|
17
18
|
import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.js";
|
|
18
19
|
import { withGitEnvironment } from "../skills.js";
|
|
19
20
|
import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.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",
|
|
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(
|
|
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
|
];
|
package/src/commands/mcp.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import readline from "node:readline";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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, {
|
|
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
|
|
38
|
-
const
|
|
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 ${
|
|
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
|
-
...(
|
|
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(
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
:
|
|
91
|
-
|
|
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
|
}
|
package/src/commands/remote.js
CHANGED
|
@@ -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.
|
|
89
|
-
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
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)}`);
|