@openagents-org/agent-launcher 0.2.145 → 0.2.147
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/package.json +1 -1
- package/src/adapters/base.js +4 -108
- package/src/adapters/gemini.js +34 -0
- package/src/adapters/workspace-prompt.js +0 -79
- package/src/cli.js +27 -0
- package/src/daemon.js +79 -46
- package/src/installer.js +63 -0
- package/src/update-check.js +26 -14
- package/src/workspace-client.js +31 -29
package/package.json
CHANGED
package/src/adapters/base.js
CHANGED
|
@@ -70,7 +70,6 @@ class BaseAdapter {
|
|
|
70
70
|
this._stopRequested = false;
|
|
71
71
|
this.client = new WorkspaceClient(this.endpoint);
|
|
72
72
|
this._lastEventId = null;
|
|
73
|
-
this._lastToolResultId = null;
|
|
74
73
|
this._running = false;
|
|
75
74
|
this._sessionId = null; // issued by server on /v1/join; used to prove liveness
|
|
76
75
|
this._processedIds = new Set();
|
|
@@ -653,31 +652,6 @@ class BaseAdapter {
|
|
|
653
652
|
idleCount++;
|
|
654
653
|
}
|
|
655
654
|
|
|
656
|
-
// Sidecar poll: A2UI tool_result events. These are the user's response
|
|
657
|
-
// to a UI spec this agent (or any agent in the network) emitted. We
|
|
658
|
-
// surface each one as a synthetic user message so the LLM sees it as
|
|
659
|
-
// the next turn and can react. Failures here don't break the main
|
|
660
|
-
// message poll.
|
|
661
|
-
try {
|
|
662
|
-
const toolResult = await this.client.pollToolResults(
|
|
663
|
-
this.workspaceId, this.token,
|
|
664
|
-
{ after: this._lastToolResultId }
|
|
665
|
-
);
|
|
666
|
-
if (toolResult.cursor) this._lastToolResultId = toolResult.cursor;
|
|
667
|
-
for (const event of toolResult.events || []) {
|
|
668
|
-
const msgId = event.id;
|
|
669
|
-
if (msgId && this._processedIds.has(msgId)) continue;
|
|
670
|
-
if (msgId) this._processedIds.add(msgId);
|
|
671
|
-
const synth = synthesizeToolResultMessage(event);
|
|
672
|
-
if (synth) await this._dispatchMessage(synth);
|
|
673
|
-
}
|
|
674
|
-
} catch (e) {
|
|
675
|
-
// Non-fatal — log once per poll if it fails
|
|
676
|
-
if (pollCount <= 3 || pollCount % 20 === 0) {
|
|
677
|
-
this._log(`tool_result poll #${pollCount} failed: ${e.message}`);
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
655
|
// Adaptive polling with warm plateau:
|
|
682
656
|
// Active (messages incoming): 2s
|
|
683
657
|
// Warm (≤5 min since last msg): 5s
|
|
@@ -811,15 +785,10 @@ class BaseAdapter {
|
|
|
811
785
|
}
|
|
812
786
|
|
|
813
787
|
async sendThinking(channel, content) {
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
// payload.spec extraction, so showing the raw block here is just noise
|
|
817
|
-
// (and a duplicate). If stripping leaves the thinking message empty,
|
|
818
|
-
// skip it entirely.
|
|
819
|
-
const { cleanContent } = extractA2UISpec(content);
|
|
820
|
-
if (!cleanContent || !cleanContent.trim()) return;
|
|
788
|
+
// Skip empty thinking traces entirely.
|
|
789
|
+
if (!content || !content.trim()) return;
|
|
821
790
|
try {
|
|
822
|
-
await this.client.sendMessage(this.workspaceId, channel, this.token,
|
|
791
|
+
await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
|
|
823
792
|
senderType: 'agent',
|
|
824
793
|
senderName: this.agentName,
|
|
825
794
|
messageType: 'thinking',
|
|
@@ -832,14 +801,11 @@ class BaseAdapter {
|
|
|
832
801
|
}
|
|
833
802
|
|
|
834
803
|
async sendResponse(channel, content) {
|
|
835
|
-
const { cleanContent, spec, specToolCallId } = extractA2UISpec(content);
|
|
836
804
|
try {
|
|
837
|
-
await this.client.sendMessage(this.workspaceId, channel, this.token,
|
|
805
|
+
await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
|
|
838
806
|
senderType: 'agent',
|
|
839
807
|
senderName: this.agentName,
|
|
840
808
|
sessionId: this._sessionId,
|
|
841
|
-
spec,
|
|
842
|
-
specToolCallId,
|
|
843
809
|
});
|
|
844
810
|
} catch (e) {
|
|
845
811
|
if (e instanceof SessionRevokedError) {
|
|
@@ -966,74 +932,4 @@ class BaseAdapter {
|
|
|
966
932
|
}
|
|
967
933
|
}
|
|
968
934
|
|
|
969
|
-
// ------------------------------------------------------------------
|
|
970
|
-
// A2UI helpers
|
|
971
|
-
// ------------------------------------------------------------------
|
|
972
|
-
|
|
973
|
-
/**
|
|
974
|
-
* Pull the first ```a2ui ... ``` fenced block out of LLM-produced content.
|
|
975
|
-
* Returns the content with the block stripped, the parsed spec, and a
|
|
976
|
-
* tool-call id derived from `spec.tool_call_id` (if present) or a new one.
|
|
977
|
-
* If no block is present or parsing fails, returns the content unchanged
|
|
978
|
-
* with null spec — the message still goes out as plain markdown.
|
|
979
|
-
*/
|
|
980
|
-
/**
|
|
981
|
-
* Convert a workspace.tool_result event into a synthetic user-message
|
|
982
|
-
* shape that the agent's _handleMessage can dispatch. The LLM sees this
|
|
983
|
-
* as the next user turn — the content is a short, machine-readable line
|
|
984
|
-
* the LLM can parse without ambiguity. The original spec it emitted is
|
|
985
|
-
* already in the LLM's conversation history; the tool_call_id lets the
|
|
986
|
-
* LLM correlate this back.
|
|
987
|
-
*/
|
|
988
|
-
function synthesizeToolResultMessage(event) {
|
|
989
|
-
if (!event || !event.payload) return null;
|
|
990
|
-
const p = event.payload;
|
|
991
|
-
const actionId = p.action_id || '';
|
|
992
|
-
const toolCallId = p.tool_call_id || '';
|
|
993
|
-
let valueStr = '';
|
|
994
|
-
if (p.value !== undefined && p.value !== null) {
|
|
995
|
-
try { valueStr = JSON.stringify(p.value); } catch (_) { valueStr = String(p.value); }
|
|
996
|
-
}
|
|
997
|
-
const lines = [
|
|
998
|
-
'[ui_action]',
|
|
999
|
-
`action=${actionId}`,
|
|
1000
|
-
toolCallId ? `tool_call_id=${toolCallId}` : null,
|
|
1001
|
-
valueStr ? `value=${valueStr}` : null,
|
|
1002
|
-
].filter(Boolean);
|
|
1003
|
-
const content = lines.join(' ');
|
|
1004
|
-
const target = event.target || '';
|
|
1005
|
-
return {
|
|
1006
|
-
messageId: event.id || '',
|
|
1007
|
-
sessionId: target.startsWith('channel/') ? target.replace('channel/', '') : target,
|
|
1008
|
-
senderType: 'human',
|
|
1009
|
-
senderName: 'user',
|
|
1010
|
-
content,
|
|
1011
|
-
mentions: [],
|
|
1012
|
-
messageType: 'chat',
|
|
1013
|
-
metadata: event.metadata || {},
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function extractA2UISpec(content) {
|
|
1018
|
-
if (!content || typeof content !== 'string') {
|
|
1019
|
-
return { cleanContent: content, spec: null, specToolCallId: null };
|
|
1020
|
-
}
|
|
1021
|
-
const match = content.match(/```a2ui\s*\n([\s\S]*?)\n```/);
|
|
1022
|
-
if (!match) return { cleanContent: content, spec: null, specToolCallId: null };
|
|
1023
|
-
|
|
1024
|
-
let spec;
|
|
1025
|
-
try {
|
|
1026
|
-
spec = JSON.parse(match[1]);
|
|
1027
|
-
} catch (_) {
|
|
1028
|
-
return { cleanContent: content, spec: null, specToolCallId: null };
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
const specToolCallId = (spec && spec.tool_call_id) || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1032
|
-
if (spec && spec.tool_call_id) delete spec.tool_call_id;
|
|
1033
|
-
|
|
1034
|
-
const cleanContent = content.replace(match[0], '').trim();
|
|
1035
|
-
return { cleanContent, spec, specToolCallId };
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
935
|
module.exports = BaseAdapter;
|
|
1039
|
-
module.exports.extractA2UISpec = extractA2UISpec;
|
package/src/adapters/gemini.js
CHANGED
|
@@ -182,7 +182,41 @@ class GeminiAdapter extends BaseAdapter {
|
|
|
182
182
|
return null;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
// The Gemini CLI refuses to run non-interactively unless an auth method is
|
|
186
|
+
// explicitly selected in ~/.gemini/settings.json ("Invalid auth method
|
|
187
|
+
// selected"), even when GEMINI_API_KEY is present. When the user configured an
|
|
188
|
+
// API key, select API-key auth (both the legacy flat and current nested
|
|
189
|
+
// schema) and disable folder-trust so `-y` isn't downgraded. We never override
|
|
190
|
+
// an existing non-api-key selection (e.g. a real OAuth sign-in).
|
|
191
|
+
_ensureGeminiAuth() {
|
|
192
|
+
if (this._geminiAuthReady) return;
|
|
193
|
+
this._geminiAuthReady = true;
|
|
194
|
+
try {
|
|
195
|
+
const env = this.agentEnv || process.env;
|
|
196
|
+
const apiKey = (env.GEMINI_API_KEY || env.GOOGLE_API_KEY || '').trim();
|
|
197
|
+
if (!apiKey) return; // OAuth / other — leave the user's setup alone.
|
|
198
|
+
const dir = path.join(os.homedir(), '.gemini');
|
|
199
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
200
|
+
const file = path.join(dir, 'settings.json');
|
|
201
|
+
let settings = {};
|
|
202
|
+
try { settings = JSON.parse(fs.readFileSync(file, 'utf-8')) || {}; } catch {}
|
|
203
|
+
const current =
|
|
204
|
+
settings.selectedAuthType ||
|
|
205
|
+
(settings.security && settings.security.auth && settings.security.auth.selectedType);
|
|
206
|
+
if (current && current !== 'gemini-api-key') return; // respect explicit OAuth/Vertex.
|
|
207
|
+
settings.selectedAuthType = 'gemini-api-key';
|
|
208
|
+
settings.security = settings.security || {};
|
|
209
|
+
settings.security.auth = { ...(settings.security.auth || {}), selectedType: 'gemini-api-key' };
|
|
210
|
+
settings.security.folderTrust = { ...(settings.security.folderTrust || {}), enabled: false };
|
|
211
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2));
|
|
212
|
+
this._log('Configured Gemini CLI for API-key auth (~/.gemini/settings.json)');
|
|
213
|
+
} catch (e) {
|
|
214
|
+
this._log(`gemini auth setup skipped: ${e.message}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
185
218
|
_buildGeminiCmd(prompt, channelName, { skipResume = false } = {}) {
|
|
219
|
+
this._ensureGeminiAuth();
|
|
186
220
|
const geminiBin = this._findGeminiBinary();
|
|
187
221
|
if (!geminiBin) {
|
|
188
222
|
throw new Error('gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
|
@@ -462,7 +462,6 @@ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = '
|
|
|
462
462
|
);
|
|
463
463
|
parts.push(buildBrowserDirective(browserEnabled));
|
|
464
464
|
parts.push(buildCollaborationPrompt());
|
|
465
|
-
parts.push(buildA2UIPrompt());
|
|
466
465
|
|
|
467
466
|
if (mode === 'plan') {
|
|
468
467
|
parts.push(
|
|
@@ -476,83 +475,6 @@ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = '
|
|
|
476
475
|
return parts.join('\n');
|
|
477
476
|
}
|
|
478
477
|
|
|
479
|
-
/**
|
|
480
|
-
* Teach the LLM how to emit interactive UI alongside its text response.
|
|
481
|
-
* The frontend (OpenAgents Go) renders any A2UI-shaped JSON spec inline
|
|
482
|
-
* in the chat bubble; the agent-connector strips the fenced block before
|
|
483
|
-
* posting and ferries the spec via payload.spec.
|
|
484
|
-
*/
|
|
485
|
-
function buildA2UIPrompt() {
|
|
486
|
-
return (
|
|
487
|
-
'\n## Rendering interactive UI\n' +
|
|
488
|
-
'When you want the user to interact with structured UI (a button choice, ' +
|
|
489
|
-
'a form, a chart, a table, a confirmation dialog) instead of just reading ' +
|
|
490
|
-
'text, emit a fenced code block tagged `a2ui` containing a JSON spec. The ' +
|
|
491
|
-
"frontend renders it inline below any markdown narration you write.\n\n" +
|
|
492
|
-
"The spec is a tree of `{ type, props, children?, action? }` nodes. " +
|
|
493
|
-
'Supported component types include `Stack`, `Heading`, `Text`, `Image`, ' +
|
|
494
|
-
'`Icon`, `Button`, `ChoiceList`, `ConfirmDialog`, `AmountInput`, `Card`, ' +
|
|
495
|
-
'`Divider`, `Spacer`, `Alert`, `LineChart`, `PieChart`, `AssetPrice`, ' +
|
|
496
|
-
'`BalanceCard`, `TransactionList`, `TransactionRow`. Unknown types render ' +
|
|
497
|
-
'as a placeholder chip, so feel free to compose freely.\n\n' +
|
|
498
|
-
'Interactive components attach an `action` object with a `name` field you ' +
|
|
499
|
-
'choose. Example:\n\n' +
|
|
500
|
-
'```a2ui\n' +
|
|
501
|
-
'{\n' +
|
|
502
|
-
' "type": "Stack",\n' +
|
|
503
|
-
' "props": { "direction": "vertical", "spacing": 12 },\n' +
|
|
504
|
-
' "children": [\n' +
|
|
505
|
-
' { "type": "Heading", "props": { "text": "Pick a time", "level": 2 } },\n' +
|
|
506
|
-
' { "type": "Button", "props": { "label": "Morning", "action": { "name": "pick_morning" } } },\n' +
|
|
507
|
-
' { "type": "Button", "props": { "label": "Evening", "action": { "name": "pick_evening" } } }\n' +
|
|
508
|
-
' ]\n' +
|
|
509
|
-
'}\n' +
|
|
510
|
-
'```\n\n' +
|
|
511
|
-
'### Exact prop names (these are checked verbatim — guessing will render "No data")\n\n' +
|
|
512
|
-
'Layout / content:\n' +
|
|
513
|
-
'- `Stack` — props: `direction` ("vertical"|"horizontal"), `spacing`, `alignment`\n' +
|
|
514
|
-
'- `Card` — props: `title`, `padding`, `cornerRadius`\n' +
|
|
515
|
-
'- `Divider`, `Spacer` — props: `size`, `orientation`\n' +
|
|
516
|
-
'- `Heading` — props: `text`, `level` (1–4)\n' +
|
|
517
|
-
'- `Text` — props: `content` **(NOT `text`)**, `style`, `weight`, `color`\n' +
|
|
518
|
-
'- `Image` — props: `url`, `name`, `contentMode`, `width`, `height`\n' +
|
|
519
|
-
'- `Icon` — props: `name`, `size`, `color`\n\n' +
|
|
520
|
-
'Interactive:\n' +
|
|
521
|
-
'- `Button` — props: `label`, `style` ("primary"|"secondary"|"destructive"), `icon`, `disabled`, `action: {name, params?}`\n' +
|
|
522
|
-
'- `ConfirmDialog` — props: `title`, `message`, `confirmLabel`, `cancelLabel`, `triggerLabel`, `action`\n' +
|
|
523
|
-
'- `ChoiceList` — props: `question`, `options: [{id, label}]`, `action`\n' +
|
|
524
|
-
'- `AmountInput` — props: `label`, `placeholder`, `currency`, `action`\n' +
|
|
525
|
-
'- `input` (lowercase) — props: `inputType` ("text"|"choice"|"number"|"date"), `id`, `label`, `placeholder`, `options`\n\n' +
|
|
526
|
-
'Feedback:\n' +
|
|
527
|
-
'- `Alert` — props: `title`, `message`, `severity` ("info"|"success"|"warning"|"error"), `dismissible`, `action`\n\n' +
|
|
528
|
-
'Charts (Swift Charts under the hood — needs **non-empty** data with the right key):\n' +
|
|
529
|
-
'- `LineChart` — props: `title`, `color`, `points: [{x, y}]` **(NOT `data`)**\n' +
|
|
530
|
-
'- `PieChart` — props: `title`, `segments: [{label, value, color?}]` **(NOT `slices`)**, `showLegend`\n' +
|
|
531
|
-
'- `chart` (lowercase) — simple sparkline/bar: props: `style` ("sparkline"|"bar"), `data: [number, ...]`, `labels`, `color`\n\n' +
|
|
532
|
-
'Data display:\n' +
|
|
533
|
-
'- `metric` (lowercase) — props: `label`, `value`, `caption`, `captionColor`, `icon`\n' +
|
|
534
|
-
'- `list` (lowercase) — props: `items: [{title, subtitle, trailing, icon}]`, `maxVisible`, `expandLabel`\n' +
|
|
535
|
-
'- `table` (lowercase) — **key-value rows only, not multi-column**: props: `rows: [{label, value}]`, `maxVisible`\n\n' +
|
|
536
|
-
'There is no multi-column data table in the catalog. For tabular data with ' +
|
|
537
|
-
'columns you have two choices: (a) use `list` with title/subtitle/trailing ' +
|
|
538
|
-
'mapped to your columns, or (b) compose a Stack of horizontal Stacks of Text ' +
|
|
539
|
-
'manually. Pick whichever reads better.\n\n' +
|
|
540
|
-
'If your data is empty or you can\'t fit it into the available primitives, ' +
|
|
541
|
-
'fall back to plain markdown — don\'t emit a spec just to wrap text in a card.\n\n' +
|
|
542
|
-
'When the user interacts with a rendered component, you will receive a ' +
|
|
543
|
-
"user message shaped like `[ui_action] action=pick_morning tool_call_id=... value=...`. " +
|
|
544
|
-
"Use the `action` name (which you chose) to decide what to do next; the " +
|
|
545
|
-
"spec you emitted earlier is in your conversation history for context.\n\n" +
|
|
546
|
-
'Use a spec when:\n' +
|
|
547
|
-
'- The user needs to make a discrete choice (offer buttons rather than ' +
|
|
548
|
-
'asking them to type a free-form answer).\n' +
|
|
549
|
-
'- Structured data is easier to scan as a chart or table than as prose.\n' +
|
|
550
|
-
'- You need explicit confirmation before a destructive action.\n' +
|
|
551
|
-
"- A form would gather several fields faster than back-and-forth chat.\n\n" +
|
|
552
|
-
"Don't emit a spec for ordinary prose answers — narration alone is fine.\n"
|
|
553
|
-
);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
478
|
/**
|
|
557
479
|
* Build the full system prompt for OpenClaw/non-MCP agents.
|
|
558
480
|
*/
|
|
@@ -561,7 +483,6 @@ function buildOpenclawSystemPrompt({ agentName, workspaceId, channelName, endpoi
|
|
|
561
483
|
parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode));
|
|
562
484
|
parts.push(buildBrowserDirective(browserEnabled));
|
|
563
485
|
parts.push(buildCollaborationPrompt());
|
|
564
|
-
parts.push(buildA2UIPrompt());
|
|
565
486
|
parts.push(buildModePrompt(mode));
|
|
566
487
|
parts.push(buildApiSkillsPrompt({
|
|
567
488
|
endpoint, workspaceId, token, agentName, channelName, disabledModules, mode,
|
package/src/cli.js
CHANGED
|
@@ -103,6 +103,19 @@ async function cmdDown(connector) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
async function cmdRestart(connector, flags) {
|
|
107
|
+
// Stop then start. stopDaemon() already SIGTERM/SIGKILLs and waits for the
|
|
108
|
+
// process to die, but poll getDaemonPid() (which checks liveness) until the
|
|
109
|
+
// pid clears so the singleton guard in cmdUp can't trip on a not-yet-gone
|
|
110
|
+
// daemon and refuse to start.
|
|
111
|
+
const stopped = connector.stopDaemon();
|
|
112
|
+
print(stopped ? 'Daemon stopped' : 'Daemon was not running');
|
|
113
|
+
for (let i = 0; i < 25 && connector.getDaemonPid(); i++) {
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
115
|
+
}
|
|
116
|
+
await cmdUp(connector, flags);
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
async function cmdStatus(connector) {
|
|
107
120
|
const pid = connector.getDaemonPid();
|
|
108
121
|
if (!pid) {
|
|
@@ -134,6 +147,18 @@ async function cmdCreate(connector, flags, positional) {
|
|
|
134
147
|
const type = flags.type || 'openclaw';
|
|
135
148
|
const role = flags.role || 'worker';
|
|
136
149
|
|
|
150
|
+
// Validate the runtime type BEFORE creating any local entry. A typo'd or
|
|
151
|
+
// otherwise unknown type (e.g. "calude") must not leave a broken agent in
|
|
152
|
+
// config — an uninstalled-but-valid type is fine and handled further down.
|
|
153
|
+
if (!connector.registry.getEntry(type)) {
|
|
154
|
+
const known = connector.registry.getCatalogSync().map((e) => e.name).sort();
|
|
155
|
+
print(`Error: unknown agent type '${type}'.`);
|
|
156
|
+
print(`Supported types: ${known.join(', ')}`);
|
|
157
|
+
print('Run `agn search` to browse available agent types.');
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
137
162
|
try {
|
|
138
163
|
connector.addAgent({ name, type, role, path: flags.path || process.cwd() });
|
|
139
164
|
|
|
@@ -708,6 +733,7 @@ async function cmdHelp() {
|
|
|
708
733
|
Commands:
|
|
709
734
|
up [--foreground] Start daemon (background by default)
|
|
710
735
|
down Stop daemon
|
|
736
|
+
restart [--foreground] Restart daemon (down + up)
|
|
711
737
|
status Show agent status
|
|
712
738
|
list List configured agents
|
|
713
739
|
create <name> [--type T] Create a new agent
|
|
@@ -792,6 +818,7 @@ async function main() {
|
|
|
792
818
|
tui: () => { const { run } = require('./tui'); run(); },
|
|
793
819
|
up: () => cmdUp(connector, flags),
|
|
794
820
|
down: () => cmdDown(connector),
|
|
821
|
+
restart: () => cmdRestart(connector, flags),
|
|
795
822
|
status: () => cmdStatus(connector),
|
|
796
823
|
list: () => cmdList(connector),
|
|
797
824
|
create: () => cmdCreate(connector, flags, positional),
|
package/src/daemon.js
CHANGED
|
@@ -235,15 +235,17 @@ class Daemon {
|
|
|
235
235
|
// Refuse to start if an existing daemon is already running.
|
|
236
236
|
// Without this check, repeated `agn up` invocations would spawn
|
|
237
237
|
// multiple daemons that each process the same message → duplicate
|
|
238
|
-
// bot replies.
|
|
239
|
-
|
|
240
|
-
|
|
238
|
+
// bot replies. Check BOTH the pid file and the status file (a live daemon
|
|
239
|
+
// rewrites the latter every 5s with its own pid): a clobbered/stale pid
|
|
240
|
+
// file must not let a duplicate slip past this guard.
|
|
241
|
+
const existingPid = Daemon.runningDaemonPid(configDir);
|
|
242
|
+
if (existingPid) {
|
|
241
243
|
console.error(`Daemon already running (PID ${existingPid}).`);
|
|
242
244
|
console.error(`Run 'agn down' first, or 'agn status' to check.`);
|
|
243
245
|
process.exit(1);
|
|
244
246
|
}
|
|
245
247
|
// Stale pid file — clean up before spawning fresh
|
|
246
|
-
if (
|
|
248
|
+
if (Daemon._readPid(pidFile)) {
|
|
247
249
|
try { fs.unlinkSync(pidFile); } catch {}
|
|
248
250
|
}
|
|
249
251
|
|
|
@@ -287,41 +289,53 @@ class Daemon {
|
|
|
287
289
|
const pidFile = path.join(configDir, 'daemon.pid');
|
|
288
290
|
const statusFile = path.join(configDir, 'daemon.status.json');
|
|
289
291
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
execSync(IS_WINDOWS ? 'ping -n 2 127.0.0.1 >nul' : 'sleep 0.5', {
|
|
309
|
-
stdio: 'ignore', timeout: 5000,
|
|
310
|
-
});
|
|
292
|
+
// Resolve the REAL running daemon(s) from BOTH the pid file and the status
|
|
293
|
+
// file. A live daemon rewrites the status file every 5s with its own pid,
|
|
294
|
+
// so when the pid file is stale/clobbered the status file is the only
|
|
295
|
+
// record of the actual process. The old code trusted only the pid file:
|
|
296
|
+
// `agn down` would signal a dead pid, delete the files, report "Daemon
|
|
297
|
+
// stopped", and leave the real daemon orphaned — which then blocked every
|
|
298
|
+
// subsequent `agn up` ("already running") and kept its agents wedged.
|
|
299
|
+
const pids = Daemon._liveDaemonPids(configDir);
|
|
300
|
+
|
|
301
|
+
// SIGTERM every distinct live pid.
|
|
302
|
+
for (const pid of pids) {
|
|
303
|
+
try {
|
|
304
|
+
if (IS_WINDOWS) {
|
|
305
|
+
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
|
|
306
|
+
} else {
|
|
307
|
+
process.kill(pid, 'SIGTERM');
|
|
308
|
+
}
|
|
309
|
+
} catch {}
|
|
311
310
|
}
|
|
312
311
|
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
312
|
+
// Wait briefly, then SIGKILL any survivor that ignored SIGTERM (this is
|
|
313
|
+
// what today's zombie required — a foreground daemon that didn't exit on
|
|
314
|
+
// SIGTERM).
|
|
315
|
+
for (const pid of pids) {
|
|
316
|
+
let alive = Daemon._isAlive(pid);
|
|
317
|
+
for (let i = 0; alive && i < 5; i++) {
|
|
318
|
+
execSync(IS_WINDOWS ? 'ping -n 2 127.0.0.1 >nul' : 'sleep 0.5', {
|
|
319
|
+
stdio: 'ignore', timeout: 5000,
|
|
320
|
+
});
|
|
321
|
+
alive = Daemon._isAlive(pid);
|
|
319
322
|
}
|
|
320
|
-
|
|
323
|
+
if (alive) {
|
|
324
|
+
try {
|
|
325
|
+
if (IS_WINDOWS) {
|
|
326
|
+
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
|
|
327
|
+
} else {
|
|
328
|
+
process.kill(pid, 'SIGKILL');
|
|
329
|
+
}
|
|
330
|
+
} catch {}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
321
333
|
|
|
334
|
+
// Always clear BOTH sources of truth so a fresh `agn up` starts clean.
|
|
322
335
|
try { fs.unlinkSync(pidFile); } catch {}
|
|
323
336
|
try { fs.unlinkSync(statusFile); } catch {}
|
|
324
|
-
|
|
337
|
+
|
|
338
|
+
return pids.length > 0;
|
|
325
339
|
}
|
|
326
340
|
|
|
327
341
|
/**
|
|
@@ -331,14 +345,18 @@ class Daemon {
|
|
|
331
345
|
const pidFile = path.join(configDir, 'daemon.pid');
|
|
332
346
|
const statusFile = path.join(configDir, 'daemon.status.json');
|
|
333
347
|
const pid = Daemon._readPid(pidFile);
|
|
334
|
-
if (
|
|
348
|
+
if (pid && Daemon._isAlive(pid)) return pid;
|
|
335
349
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
350
|
+
// pid file missing or stale — fall back to the status file so `agn status`
|
|
351
|
+
// reports the SAME live daemon the `agn up` singleton guard detects.
|
|
352
|
+
// Otherwise status says "not running" (dead pid file) while up refuses
|
|
353
|
+
// ("already running", from the status file) — the exact contradiction that
|
|
354
|
+
// hid today's orphaned daemon.
|
|
355
|
+
const live = Daemon.runningDaemonPid(configDir);
|
|
356
|
+
if (live) return live;
|
|
340
357
|
|
|
341
|
-
|
|
358
|
+
Daemon._cleanupStaleDaemonFiles(pidFile, statusFile);
|
|
359
|
+
return null;
|
|
342
360
|
}
|
|
343
361
|
|
|
344
362
|
// ---------------------------------------------------------------------------
|
|
@@ -997,12 +1015,27 @@ class Daemon {
|
|
|
997
1015
|
* stopped for a legitimate restart doesn't block the replacement.
|
|
998
1016
|
*/
|
|
999
1017
|
static runningDaemonPid(configDir) {
|
|
1018
|
+
return Daemon._liveDaemonPids(configDir)[0] || null;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Return all distinct live daemon PIDs for this configDir (never including
|
|
1023
|
+
* the calling process), gathered from BOTH the pid file and the status file.
|
|
1024
|
+
* The pid file is considered first so its PID sorts ahead of the status
|
|
1025
|
+
* file's. Used by the singleton guard, `agn status`, and `agn down` so every
|
|
1026
|
+
* command agrees on which process(es) are the daemon — the divergence that
|
|
1027
|
+
* let a stale pid file orphan a running daemon.
|
|
1028
|
+
*/
|
|
1029
|
+
static _liveDaemonPids(configDir) {
|
|
1000
1030
|
const self = process.pid;
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1031
|
+
const pids = [];
|
|
1032
|
+
const consider = (pid) => {
|
|
1033
|
+
if (pid && pid !== self && Daemon._isAlive(pid) && !pids.includes(pid)) {
|
|
1034
|
+
pids.push(pid);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1003
1037
|
|
|
1004
|
-
|
|
1005
|
-
if (isOtherAlive(pidFromFile)) return pidFromFile;
|
|
1038
|
+
consider(Daemon._readPid(path.join(configDir, 'daemon.pid')));
|
|
1006
1039
|
|
|
1007
1040
|
try {
|
|
1008
1041
|
const statusFile = path.join(configDir, 'daemon.status.json');
|
|
@@ -1011,11 +1044,11 @@ class Daemon {
|
|
|
1011
1044
|
// unrelated process can't masquerade as a live daemon.
|
|
1012
1045
|
if (age < 30000) {
|
|
1013
1046
|
const raw = JSON.parse(fs.readFileSync(statusFile, 'utf-8'));
|
|
1014
|
-
|
|
1015
|
-
if (isOtherAlive(pid)) return pid;
|
|
1047
|
+
consider(raw && raw.pid);
|
|
1016
1048
|
}
|
|
1017
1049
|
} catch {}
|
|
1018
|
-
|
|
1050
|
+
|
|
1051
|
+
return pids;
|
|
1019
1052
|
}
|
|
1020
1053
|
}
|
|
1021
1054
|
|
package/src/installer.js
CHANGED
|
@@ -356,6 +356,57 @@ class Installer {
|
|
|
356
356
|
);
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
// Hermes's install.ps1 can print "[X] Installation failed" (e.g. its bundled
|
|
360
|
+
// `uv` step fails) yet still exit 0, so exit-code-only success is unreliable.
|
|
361
|
+
// Confirm a real hermes binary exists before recording the install. Mirrors
|
|
362
|
+
// the paths the HermesAdapter._findHermesBinary() searches.
|
|
363
|
+
_verifyHermesBinary() {
|
|
364
|
+
try { clearBinaryLookupCache(); } catch {}
|
|
365
|
+
const isWin = process.platform === 'win32';
|
|
366
|
+
const home = os.homedir();
|
|
367
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
368
|
+
const candidates = [];
|
|
369
|
+
const resolved = this._whichBinary('hermes');
|
|
370
|
+
if (resolved) candidates.push(resolved);
|
|
371
|
+
if (isWin) {
|
|
372
|
+
candidates.push(
|
|
373
|
+
path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.exe'),
|
|
374
|
+
path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.cmd'),
|
|
375
|
+
path.join(localAppData, 'hermes', 'bin', 'hermes.exe'),
|
|
376
|
+
path.join(localAppData, 'hermes', 'bin', 'hermes.cmd'),
|
|
377
|
+
path.join(home, '.hermes', 'bin', 'hermes.exe'),
|
|
378
|
+
path.join(home, '.hermes', 'bin', 'hermes.cmd'),
|
|
379
|
+
path.join(home, '.local', 'bin', 'hermes.exe'),
|
|
380
|
+
path.join(home, '.local', 'bin', 'hermes.cmd'),
|
|
381
|
+
);
|
|
382
|
+
} else {
|
|
383
|
+
candidates.push(
|
|
384
|
+
path.join(home, '.hermes', 'bin', 'hermes'),
|
|
385
|
+
path.join(home, '.local', 'bin', 'hermes'),
|
|
386
|
+
'/opt/homebrew/bin/hermes',
|
|
387
|
+
'/usr/local/bin/hermes',
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
let found = null;
|
|
391
|
+
for (const c of candidates) {
|
|
392
|
+
try { if (c && fs.existsSync(c)) { found = c; break; } } catch {}
|
|
393
|
+
}
|
|
394
|
+
return found ? { path: found } : null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
_hermesBinaryNotFoundMessage() {
|
|
398
|
+
const isWin = process.platform === 'win32';
|
|
399
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
400
|
+
const expected = isWin
|
|
401
|
+
? path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.exe')
|
|
402
|
+
: path.join(os.homedir(), '.hermes', 'bin', 'hermes');
|
|
403
|
+
return (
|
|
404
|
+
'Hermes install command completed, but the Hermes CLI binary could not be found\n' +
|
|
405
|
+
"(its installer can report a uv/setup failure yet still exit 0).\n\n" +
|
|
406
|
+
`Expected path:\n${expected}`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
359
410
|
/**
|
|
360
411
|
* Find the binary path for an agent type.
|
|
361
412
|
*/
|
|
@@ -953,6 +1004,18 @@ class Installer {
|
|
|
953
1004
|
}
|
|
954
1005
|
if (onData) onData(`\nAmp CLI resolved: ${amp.path}${amp.version ? ` (${amp.version})` : ''}\n`);
|
|
955
1006
|
}
|
|
1007
|
+
// Hermes-only: its install.ps1 can exit 0 after a failed uv/setup step,
|
|
1008
|
+
// so verify a real binary exists before recording the install.
|
|
1009
|
+
if (agentType === 'hermes') {
|
|
1010
|
+
const hermes = this._verifyHermesBinary();
|
|
1011
|
+
if (!hermes) {
|
|
1012
|
+
const msg = this._hermesBinaryNotFoundMessage();
|
|
1013
|
+
if (onData) onData(`\n${msg}\n`);
|
|
1014
|
+
reject(new Error(msg));
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (onData) onData(`\nHermes CLI resolved: ${hermes.path}\n`);
|
|
1018
|
+
}
|
|
956
1019
|
this._markInstalled(agentType);
|
|
957
1020
|
if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
|
|
958
1021
|
resolve({ success: true, command: displayCmd });
|
package/src/update-check.js
CHANGED
|
@@ -88,20 +88,31 @@ async function checkForUpdate() {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
* Derive the npm `--prefix` for a global install from the npm binary location.
|
|
92
|
+
*
|
|
93
|
+
* npm resolves `-g` installs relative to a PREFIX, and the layout differs by
|
|
94
|
+
* platform:
|
|
95
|
+
* Unix: packages → {prefix}/lib/node_modules (npm bin at {prefix}/bin)
|
|
96
|
+
* Windows: packages → {prefix}/node_modules (npm bin at {prefix})
|
|
97
|
+
*
|
|
98
|
+
* So the prefix is the PARENT of npm's bin dir on Unix, and the bin dir itself
|
|
99
|
+
* on Windows. The previous implementation walked up to the enclosing
|
|
100
|
+
* node_modules and returned the directory containing it — which on Unix is
|
|
101
|
+
* `{prefix}/lib`. Passing THAT as `--prefix` made npm append `lib/node_modules`
|
|
102
|
+
* again, installing into `{prefix}/lib/lib/node_modules` while the running `agn`
|
|
103
|
+
* (loaded from `{prefix}/lib/node_modules`) never changed: `agn update` reported
|
|
104
|
+
* success but silently no-op'd on every Unix host.
|
|
93
105
|
*/
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return null;
|
|
106
|
+
function npmPrefixFromBin(npmBin, platform = process.platform) {
|
|
107
|
+
if (!npmBin) return null;
|
|
108
|
+
// Use the target platform's path semantics so a Windows path resolves
|
|
109
|
+
// correctly even when this runs on a POSIX host (and vice versa).
|
|
110
|
+
const p = platform === 'win32' ? path.win32 : path.posix;
|
|
111
|
+
const binDir = p.dirname(npmBin);
|
|
112
|
+
// A bare name (e.g. 'npm' from the PATH hard-fallback) has no usable
|
|
113
|
+
// directory — let npm resolve its own default global prefix instead.
|
|
114
|
+
if (binDir === '.' || binDir === '') return null;
|
|
115
|
+
return platform === 'win32' ? binDir : p.dirname(binDir);
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
/**
|
|
@@ -139,7 +150,7 @@ function runUpdate(opts = {}) {
|
|
|
139
150
|
const platform = opts.platform || process.platform;
|
|
140
151
|
const spawn = opts.spawn || spawnSync;
|
|
141
152
|
const npmBin = opts.npmBin || findNpmBin({ platform });
|
|
142
|
-
const prefix = opts.prefix !== undefined ? opts.prefix :
|
|
153
|
+
const prefix = opts.prefix !== undefined ? opts.prefix : npmPrefixFromBin(npmBin, platform);
|
|
143
154
|
// Use a GLOBAL install (-g). A LOCAL install (`npm install <pkg> --prefix
|
|
144
155
|
// <dir>`) treats <dir> as a project root and prunes every node_modules entry
|
|
145
156
|
// not reachable from its package.json. When the prefix is the portable node
|
|
@@ -228,5 +239,6 @@ module.exports = {
|
|
|
228
239
|
notifyAndMaybeUpdate,
|
|
229
240
|
runUpdate,
|
|
230
241
|
findNpmBin,
|
|
242
|
+
npmPrefixFromBin,
|
|
231
243
|
currentVersion,
|
|
232
244
|
};
|
package/src/workspace-client.js
CHANGED
|
@@ -156,15 +156,12 @@ class WorkspaceClient {
|
|
|
156
156
|
*/
|
|
157
157
|
async sendMessage(workspaceId, channelName, token, content, {
|
|
158
158
|
senderType = 'agent', senderName, messageType = 'chat', metadata, attachments, sessionId,
|
|
159
|
-
spec, specToolCallId,
|
|
160
159
|
} = {}) {
|
|
161
160
|
const sourcePrefix = senderType === 'agent' ? 'openagents' : 'human';
|
|
162
161
|
const source = senderName ? `${sourcePrefix}:${senderName}` : `${sourcePrefix}:unknown`;
|
|
163
162
|
|
|
164
163
|
const payload = { content, message_type: messageType };
|
|
165
164
|
if (attachments && attachments.length) payload.attachments = attachments;
|
|
166
|
-
if (spec) payload.spec = spec;
|
|
167
|
-
if (specToolCallId) payload.spec_tool_call_id = specToolCallId;
|
|
168
165
|
|
|
169
166
|
return this.sendEvent(workspaceId, {
|
|
170
167
|
type: 'workspace.message.posted',
|
|
@@ -175,30 +172,6 @@ class WorkspaceClient {
|
|
|
175
172
|
}, token, sessionId);
|
|
176
173
|
}
|
|
177
174
|
|
|
178
|
-
/**
|
|
179
|
-
* Poll workspace.tool_result events — the client's response to an agent's
|
|
180
|
-
* render_ui invocation. Returns events directly (no message normalization)
|
|
181
|
-
* because the caller treats them differently than chat messages.
|
|
182
|
-
*/
|
|
183
|
-
async pollToolResults(workspaceId, token, { after, limit = 50 } = {}) {
|
|
184
|
-
const params = new URLSearchParams({
|
|
185
|
-
network: workspaceId,
|
|
186
|
-
type: 'workspace.tool_result',
|
|
187
|
-
limit: String(limit),
|
|
188
|
-
});
|
|
189
|
-
if (after) params.set('after', after);
|
|
190
|
-
|
|
191
|
-
const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
|
|
192
|
-
const result = data.data || data;
|
|
193
|
-
const events = (result && result.events) || [];
|
|
194
|
-
|
|
195
|
-
let cursor = null;
|
|
196
|
-
if (events.length > 0) {
|
|
197
|
-
cursor = events[events.length - 1].id || null;
|
|
198
|
-
}
|
|
199
|
-
return { events, cursor };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
175
|
/**
|
|
203
176
|
* Poll messages in a channel via GET /v1/events.
|
|
204
177
|
* @returns {Array} message-compatible objects
|
|
@@ -275,6 +248,11 @@ class WorkspaceClient {
|
|
|
275
248
|
const params = new URLSearchParams({
|
|
276
249
|
network: workspaceId,
|
|
277
250
|
type: 'workspace.message.posted',
|
|
251
|
+
// Server-side pre-filter: only return events routed to this agent (plus
|
|
252
|
+
// untargeted ones) instead of the whole network's traffic. Backward
|
|
253
|
+
// compatible — older backends ignore the unknown param and return
|
|
254
|
+
// everything, so the client-side filter below stays as the safety net.
|
|
255
|
+
target_agents: agentName,
|
|
278
256
|
limit: String(limit),
|
|
279
257
|
});
|
|
280
258
|
if (after) params.set('after', after);
|
|
@@ -283,12 +261,23 @@ class WorkspaceClient {
|
|
|
283
261
|
const result = data.data || data;
|
|
284
262
|
const events = (result && result.events) || [];
|
|
285
263
|
|
|
264
|
+
// Prefer the server's next_cursor: with server-side target filtering the
|
|
265
|
+
// last returned event lags the stream tip, so advancing by it alone would
|
|
266
|
+
// re-scan the same range every poll. next_cursor jumps past other agents'
|
|
267
|
+
// events once ours are drained. Older backends don't send it — fall back
|
|
268
|
+
// to the last returned event id (they return the full stream, so that IS
|
|
269
|
+
// the tip).
|
|
286
270
|
let cursor = null;
|
|
287
|
-
if (
|
|
271
|
+
if (result && result.next_cursor) {
|
|
272
|
+
cursor = result.next_cursor;
|
|
273
|
+
} else if (events.length > 0) {
|
|
288
274
|
cursor = events[events.length - 1].id || null;
|
|
289
275
|
}
|
|
290
276
|
|
|
291
|
-
// Filter for messages targeted at this agent.
|
|
277
|
+
// Filter for messages targeted at this agent. With a targeting-aware
|
|
278
|
+
// backend this is mostly a no-op (server already narrowed the set); it
|
|
279
|
+
// remains authoritative for older backends and for the untargeted events
|
|
280
|
+
// the server includes as a safe superset.
|
|
292
281
|
//
|
|
293
282
|
// target_agents semantics:
|
|
294
283
|
// • absent → legacy server with no routing decision
|
|
@@ -802,6 +791,14 @@ class WorkspaceClient {
|
|
|
802
791
|
method: 'GET',
|
|
803
792
|
headers,
|
|
804
793
|
timeout,
|
|
794
|
+
// Hard end-to-end deadline. The `timeout` option above is only a SOCKET
|
|
795
|
+
// inactivity timeout — it is NOT armed until a socket exists, so it does
|
|
796
|
+
// not bound DNS resolution or TCP connect. During a network partition a
|
|
797
|
+
// request stuck in getaddrinfo/connect never fires 'timeout', so the
|
|
798
|
+
// await never settles and the caller's single poll loop wedges forever
|
|
799
|
+
// (the agent stays "online" via its separate heartbeat but stops
|
|
800
|
+
// consuming messages). AbortSignal.timeout fires in ANY phase.
|
|
801
|
+
signal: AbortSignal.timeout(timeout),
|
|
805
802
|
}, (res) => {
|
|
806
803
|
let data = '';
|
|
807
804
|
res.on('data', (chunk) => { data += chunk; });
|
|
@@ -836,6 +833,7 @@ class WorkspaceClient {
|
|
|
836
833
|
method: 'GET',
|
|
837
834
|
headers,
|
|
838
835
|
timeout,
|
|
836
|
+
signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
|
|
839
837
|
}, (res) => {
|
|
840
838
|
const chunks = [];
|
|
841
839
|
res.on('data', (chunk) => { chunks.push(chunk); });
|
|
@@ -868,6 +866,7 @@ class WorkspaceClient {
|
|
|
868
866
|
method: 'POST',
|
|
869
867
|
headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
|
|
870
868
|
timeout,
|
|
869
|
+
signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
|
|
871
870
|
}, (res) => {
|
|
872
871
|
let data = '';
|
|
873
872
|
res.on('data', (chunk) => { data += chunk; });
|
|
@@ -910,6 +909,7 @@ class WorkspaceClient {
|
|
|
910
909
|
method: 'PUT',
|
|
911
910
|
headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
|
|
912
911
|
timeout,
|
|
912
|
+
signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
|
|
913
913
|
}, (res) => {
|
|
914
914
|
let data = '';
|
|
915
915
|
res.on('data', (chunk) => { data += chunk; });
|
|
@@ -952,6 +952,7 @@ class WorkspaceClient {
|
|
|
952
952
|
method: 'PATCH',
|
|
953
953
|
headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
|
|
954
954
|
timeout,
|
|
955
|
+
signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
|
|
955
956
|
}, (res) => {
|
|
956
957
|
let data = '';
|
|
957
958
|
res.on('data', (chunk) => { data += chunk; });
|
|
@@ -987,6 +988,7 @@ class WorkspaceClient {
|
|
|
987
988
|
method: 'DELETE',
|
|
988
989
|
headers,
|
|
989
990
|
timeout: 15000,
|
|
991
|
+
signal: AbortSignal.timeout(15000), // hard deadline (covers DNS/connect); see _get
|
|
990
992
|
}, (res) => {
|
|
991
993
|
let data = '';
|
|
992
994
|
res.on('data', (chunk) => { data += chunk; });
|