@hwip/cli 0.0.1 → 0.1.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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1979 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +292 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +272 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5680 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AgentKind } from './remote-agents.js';
|
|
2
|
+
export type AgentState = 'working' | 'blocked' | 'idle' | 'unknown';
|
|
3
|
+
export interface RuleCondition {
|
|
4
|
+
/** All strings must appear (case-insensitive). */
|
|
5
|
+
contains?: string[];
|
|
6
|
+
/** All patterns must match. */
|
|
7
|
+
regex?: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface DetectionRule {
|
|
10
|
+
id: string;
|
|
11
|
+
state: AgentState;
|
|
12
|
+
/** Higher wins; first match ends evaluation. */
|
|
13
|
+
priority: number;
|
|
14
|
+
/** 'tail' = last N non-empty lines (default), 'whole' = full capture. */
|
|
15
|
+
region?: 'tail' | 'whole';
|
|
16
|
+
/** Lines for region 'tail'. Default 10. */
|
|
17
|
+
tailLines?: number;
|
|
18
|
+
contains?: string[];
|
|
19
|
+
regex?: string[];
|
|
20
|
+
/** At least one condition group must match (OR). */
|
|
21
|
+
any?: RuleCondition[];
|
|
22
|
+
/** No condition group may match (exclusion). */
|
|
23
|
+
not?: RuleCondition[];
|
|
24
|
+
/**
|
|
25
|
+
* On match, keep the previous state instead of this rule's state.
|
|
26
|
+
* For overlay screens (transcript viewer, menus) that would
|
|
27
|
+
* otherwise pollute the state while the agent is still working.
|
|
28
|
+
*/
|
|
29
|
+
skipStateUpdate?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface DetectionManifest {
|
|
32
|
+
engineVersion: number;
|
|
33
|
+
/** Date.revision, e.g. "2026.07.04.1" — for OTA freshness compare. */
|
|
34
|
+
version: string;
|
|
35
|
+
/** Kill-switch: rule ids to ignore without shipping a new manifest shape. */
|
|
36
|
+
disabledRuleIds?: string[];
|
|
37
|
+
agents: Partial<Record<AgentKind, DetectionRule[]>>;
|
|
38
|
+
}
|
|
39
|
+
export declare const ENGINE_VERSION = 1;
|
|
40
|
+
export interface EvaluationResult {
|
|
41
|
+
state: AgentState;
|
|
42
|
+
/** Rule that decided the state — for verbose logs and rule debugging. */
|
|
43
|
+
ruleId?: string;
|
|
44
|
+
}
|
|
45
|
+
/** Why a rule or watch pattern will never match, or null when it compiles. */
|
|
46
|
+
export declare const patternError: (pattern: string) => string | null;
|
|
47
|
+
/** Test hook: manifest swaps call this so stale patterns don't pin memory. */
|
|
48
|
+
export declare const clearRegexCache: () => void;
|
|
49
|
+
/**
|
|
50
|
+
* Classify one pane capture. `prev` feeds skipStateUpdate rules — an
|
|
51
|
+
* overlay match returns the previous confirmed state unchanged.
|
|
52
|
+
*/
|
|
53
|
+
export declare const evaluateState: (paneText: string, agentKind: AgentKind, manifest: DetectionManifest, prev?: AgentState) => EvaluationResult;
|
|
54
|
+
/**
|
|
55
|
+
* Safe one-pattern probe for output-match watches (§S5 v2). Same caps
|
|
56
|
+
* as rule evaluation — user-authored watch patterns are exactly as
|
|
57
|
+
* untrusted as OTA rules. Returns the matched line for the push body.
|
|
58
|
+
*/
|
|
59
|
+
export declare const findPatternMatch: (pattern: string, paneText: string) => {
|
|
60
|
+
line: string;
|
|
61
|
+
} | null;
|
|
62
|
+
export interface StateTracker {
|
|
63
|
+
/** Last CONFIRMED state — what gets reported to the server. */
|
|
64
|
+
confirmed: AgentState;
|
|
65
|
+
confirmedAt: number;
|
|
66
|
+
ruleId?: string;
|
|
67
|
+
/** Pending different observation awaiting its second sighting. */
|
|
68
|
+
candidate?: AgentState;
|
|
69
|
+
candidateRuleId?: string;
|
|
70
|
+
/** Hash of the pane text behind the last evaluation (skip re-eval). */
|
|
71
|
+
contentHash?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Consecutive-confirmation debounce: a NEW state must be observed on
|
|
75
|
+
* two consecutive cycles (~10 s at the 5 s report interval) before it
|
|
76
|
+
* replaces the confirmed one. Menus flashed open, mid-render frames,
|
|
77
|
+
* and scroll artifacts all last one cycle and die as candidates.
|
|
78
|
+
*
|
|
79
|
+
* The very first observation confirms immediately — a fresh tracker
|
|
80
|
+
* has no baseline to protect, and the server treats a session's first
|
|
81
|
+
* reported state as baseline, not as a transition (§S2).
|
|
82
|
+
*/
|
|
83
|
+
export declare const advanceState: (tracker: StateTracker | undefined, observed: EvaluationResult, now: number) => StateTracker;
|
|
84
|
+
//# sourceMappingURL=agent-state.d.ts.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.advanceState = exports.findPatternMatch = exports.evaluateState = exports.clearRegexCache = exports.patternError = exports.ENGINE_VERSION = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Agent state detection engine — classifies a tmux pane's visible text
|
|
6
|
+
* into `working | blocked | idle | unknown` using declarative rules.
|
|
7
|
+
*
|
|
8
|
+
* Design constraints:
|
|
9
|
+
* - Rules are DATA, not code: they ship bundled but are replaceable
|
|
10
|
+
* over the air (§S7), so agent UI changes never require a daemon
|
|
11
|
+
* release. Everything here must therefore survive hostile or
|
|
12
|
+
* malformed manifests: pattern-length caps, input-size caps, and
|
|
13
|
+
* per-rule isolation of regex compile failures.
|
|
14
|
+
* - Pure functions only. The listener owns timing, tmux, and I/O;
|
|
15
|
+
* this module owns classification and flap suppression so both are
|
|
16
|
+
* unit-testable without a terminal.
|
|
17
|
+
* - `done` is deliberately NOT a wire state: "finished but unseen" is
|
|
18
|
+
* per-user view state, derived client-side. The daemon reports only
|
|
19
|
+
* what it can observe.
|
|
20
|
+
*/
|
|
21
|
+
const re2js_1 = require("re2js");
|
|
22
|
+
exports.ENGINE_VERSION = 1;
|
|
23
|
+
// OTA manifests are semi-trusted (schema-validated, authored by humans,
|
|
24
|
+
// fetched over the network) and watch patterns come from the phone. Both run
|
|
25
|
+
// synchronously on the daemon's one thread, where a backtracking engine is a
|
|
26
|
+
// freeze waiting to happen: `(a+)+$` hangs V8 on 29 characters and
|
|
27
|
+
// `.*.*.*x` takes seconds on 300. So patterns run on RE2 (re2js, pure JS),
|
|
28
|
+
// whose time is the input length times the compiled program's size. Counted
|
|
29
|
+
// repeats grow that program (`\w{1,1000}\w{1,1000}z` takes seconds on 8 KiB),
|
|
30
|
+
// so a program past MAX_PROGRAM_SIZE is refused too. RE2 has no lookaround or
|
|
31
|
+
// backreferences — a pattern using them does not compile and its rule or
|
|
32
|
+
// watch stays inert — its `\s`, `\w`, `\d` are ASCII-only, and `$` does not
|
|
33
|
+
// match before `\r`.
|
|
34
|
+
// hwip server/src/lib/watch-pattern.ts applies the same limits and flags to a
|
|
35
|
+
// watch pattern before storing it (C75) — keep the two in step.
|
|
36
|
+
const MAX_PATTERN_LENGTH = 200;
|
|
37
|
+
/**
|
|
38
|
+
* Bundled and pi rules compile to 6–37 steps. At 200, a match search over
|
|
39
|
+
* 8 KiB measured 100–300 ms; at 64 it stays under about 40 ms.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_PROGRAM_SIZE = 64;
|
|
42
|
+
const MAX_INPUT_BYTES = 8 * 1024;
|
|
43
|
+
const DEFAULT_TAIL_LINES = 10;
|
|
44
|
+
/** The last MAX_INPUT_BYTES of the pane as UTF-8 — the bottom is the signal. */
|
|
45
|
+
const tailBytes = (text) => {
|
|
46
|
+
if (Buffer.byteLength(text, 'utf-8') <= MAX_INPUT_BYTES)
|
|
47
|
+
return text;
|
|
48
|
+
const bytes = Buffer.from(text, 'utf-8');
|
|
49
|
+
// Drop what is left of a character the cut went through.
|
|
50
|
+
return bytes.subarray(-MAX_INPUT_BYTES).toString('utf-8').replace(/^\uFFFD+/, '');
|
|
51
|
+
};
|
|
52
|
+
// Compiled-regex cache. Rules are static between manifest swaps, so
|
|
53
|
+
// compiling once per (pattern) is enough; a failed compile is cached as
|
|
54
|
+
// its reason so a bad OTA pattern logs once, not every 5-second cycle.
|
|
55
|
+
const regexCache = new Map();
|
|
56
|
+
const compilePattern = (pattern) => {
|
|
57
|
+
const cached = regexCache.get(pattern);
|
|
58
|
+
if (cached !== undefined)
|
|
59
|
+
return cached;
|
|
60
|
+
let compiled;
|
|
61
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
62
|
+
compiled = `longer than ${MAX_PATTERN_LENGTH} characters`;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
try {
|
|
66
|
+
compiled = re2js_1.RE2JS.compile(pattern, re2js_1.RE2JS.CASE_INSENSITIVE | re2js_1.RE2JS.MULTILINE);
|
|
67
|
+
if (compiled.programSize() > MAX_PROGRAM_SIZE)
|
|
68
|
+
compiled = `compiles to more than ${MAX_PROGRAM_SIZE} steps`;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
compiled = err instanceof Error ? err.message : String(err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
regexCache.set(pattern, compiled);
|
|
75
|
+
return compiled;
|
|
76
|
+
};
|
|
77
|
+
/** Why a rule or watch pattern will never match, or null when it compiles. */
|
|
78
|
+
const patternError = (pattern) => {
|
|
79
|
+
const compiled = compilePattern(pattern);
|
|
80
|
+
return typeof compiled === 'string' ? compiled : null;
|
|
81
|
+
};
|
|
82
|
+
exports.patternError = patternError;
|
|
83
|
+
/** Test hook: manifest swaps call this so stale patterns don't pin memory. */
|
|
84
|
+
const clearRegexCache = () => {
|
|
85
|
+
regexCache.clear();
|
|
86
|
+
};
|
|
87
|
+
exports.clearRegexCache = clearRegexCache;
|
|
88
|
+
const conditionMatches = (cond, text, lowerText) => {
|
|
89
|
+
for (const needle of cond.contains ?? []) {
|
|
90
|
+
if (!lowerText.includes(needle.toLowerCase()))
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
for (const pattern of cond.regex ?? []) {
|
|
94
|
+
const compiled = compilePattern(pattern);
|
|
95
|
+
// A pattern that failed to compile can never be satisfied —
|
|
96
|
+
// fail the condition rather than silently passing it, so a
|
|
97
|
+
// broken OTA rule becomes inert instead of over-matching.
|
|
98
|
+
if (typeof compiled === 'string' || !compiled.test(text))
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
};
|
|
103
|
+
const ruleMatches = (rule, text, lowerText) => {
|
|
104
|
+
if (!conditionMatches({ contains: rule.contains, regex: rule.regex }, text, lowerText))
|
|
105
|
+
return false;
|
|
106
|
+
if (rule.any && rule.any.length > 0) {
|
|
107
|
+
if (!rule.any.some((c) => conditionMatches(c, text, lowerText)))
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
for (const excluded of rule.not ?? []) {
|
|
111
|
+
if (conditionMatches(excluded, text, lowerText))
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
const tailOf = (lines, count) => lines.filter((l) => l.trim().length > 0).slice(-count).join('\n');
|
|
117
|
+
/**
|
|
118
|
+
* Classify one pane capture. `prev` feeds skipStateUpdate rules — an
|
|
119
|
+
* overlay match returns the previous confirmed state unchanged.
|
|
120
|
+
*/
|
|
121
|
+
const evaluateState = (paneText, agentKind, manifest, prev = 'unknown') => {
|
|
122
|
+
// Truncate from the FRONT: the bottom of the pane is where every
|
|
123
|
+
// agent renders its live status, so the tail is the signal.
|
|
124
|
+
const text = tailBytes(paneText);
|
|
125
|
+
const lines = text.split('\n');
|
|
126
|
+
const disabled = new Set(manifest.disabledRuleIds ?? []);
|
|
127
|
+
const rules = (manifest.agents[agentKind] ?? [])
|
|
128
|
+
.filter((r) => !disabled.has(r.id))
|
|
129
|
+
.sort((a, b) => b.priority - a.priority);
|
|
130
|
+
for (const rule of rules) {
|
|
131
|
+
const scope = rule.region === 'whole'
|
|
132
|
+
? text
|
|
133
|
+
: tailOf(lines, rule.tailLines ?? DEFAULT_TAIL_LINES);
|
|
134
|
+
if (ruleMatches(rule, scope, scope.toLowerCase())) {
|
|
135
|
+
if (rule.skipStateUpdate)
|
|
136
|
+
return { state: prev, ruleId: rule.id };
|
|
137
|
+
return { state: rule.state, ruleId: rule.id };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { state: 'unknown' };
|
|
141
|
+
};
|
|
142
|
+
exports.evaluateState = evaluateState;
|
|
143
|
+
/**
|
|
144
|
+
* Safe one-pattern probe for output-match watches (§S5 v2). Same caps
|
|
145
|
+
* as rule evaluation — user-authored watch patterns are exactly as
|
|
146
|
+
* untrusted as OTA rules. Returns the matched line for the push body.
|
|
147
|
+
*/
|
|
148
|
+
const findPatternMatch = (pattern, paneText) => {
|
|
149
|
+
const text = tailBytes(paneText);
|
|
150
|
+
const compiled = compilePattern(pattern);
|
|
151
|
+
// `test` is the fast path; only a pane that matches pays for `find`.
|
|
152
|
+
if (typeof compiled === 'string' || !compiled.test(text))
|
|
153
|
+
return null;
|
|
154
|
+
const matcher = compiled.matcher(text);
|
|
155
|
+
if (!matcher.find())
|
|
156
|
+
return null;
|
|
157
|
+
const at = matcher.start();
|
|
158
|
+
const start = text.lastIndexOf('\n', at) + 1;
|
|
159
|
+
const endIdx = text.indexOf('\n', at);
|
|
160
|
+
const line = text.slice(start, endIdx === -1 ? undefined : endIdx).trim();
|
|
161
|
+
return { line };
|
|
162
|
+
};
|
|
163
|
+
exports.findPatternMatch = findPatternMatch;
|
|
164
|
+
/**
|
|
165
|
+
* Consecutive-confirmation debounce: a NEW state must be observed on
|
|
166
|
+
* two consecutive cycles (~10 s at the 5 s report interval) before it
|
|
167
|
+
* replaces the confirmed one. Menus flashed open, mid-render frames,
|
|
168
|
+
* and scroll artifacts all last one cycle and die as candidates.
|
|
169
|
+
*
|
|
170
|
+
* The very first observation confirms immediately — a fresh tracker
|
|
171
|
+
* has no baseline to protect, and the server treats a session's first
|
|
172
|
+
* reported state as baseline, not as a transition (§S2).
|
|
173
|
+
*/
|
|
174
|
+
const advanceState = (tracker, observed, now) => {
|
|
175
|
+
if (!tracker) {
|
|
176
|
+
return { confirmed: observed.state, confirmedAt: now, ruleId: observed.ruleId };
|
|
177
|
+
}
|
|
178
|
+
if (observed.state === tracker.confirmed) {
|
|
179
|
+
// Re-confirmation clears any pending candidate.
|
|
180
|
+
return { ...tracker, candidate: undefined, candidateRuleId: undefined, ruleId: observed.ruleId ?? tracker.ruleId };
|
|
181
|
+
}
|
|
182
|
+
if (observed.state === tracker.candidate) {
|
|
183
|
+
// Second consecutive sighting — promote.
|
|
184
|
+
return { confirmed: observed.state, confirmedAt: now, ruleId: observed.ruleId };
|
|
185
|
+
}
|
|
186
|
+
return { ...tracker, candidate: observed.state, candidateRuleId: observed.ruleId };
|
|
187
|
+
};
|
|
188
|
+
exports.advanceState = advanceState;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** A subset of a `/devices` record (hwip `Device`). */
|
|
2
|
+
export interface AgentTargetDevice {
|
|
3
|
+
deviceId: string;
|
|
4
|
+
nickname?: string;
|
|
5
|
+
isOnline?: boolean;
|
|
6
|
+
agentSessions?: {
|
|
7
|
+
name: string;
|
|
8
|
+
project?: string;
|
|
9
|
+
label?: string | null;
|
|
10
|
+
providerSessionName?: string | null;
|
|
11
|
+
parentName?: string;
|
|
12
|
+
/** What runs in the session: an agent kind, or `shell` for a `hwip sh` session. */
|
|
13
|
+
agentKind?: string;
|
|
14
|
+
}[];
|
|
15
|
+
agentSessionAliases?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
/** A resolved agent session: the listener's device id and its tmux name. */
|
|
18
|
+
export interface AgentTarget {
|
|
19
|
+
deviceId: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/** `<deviceId>:<name>` — the stable key the app groups the session's chat by. */
|
|
22
|
+
key: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The target named nothing, named more than one session, or is on a machine
|
|
26
|
+
* that is offline. The message lists the keys to retry with.
|
|
27
|
+
*/
|
|
28
|
+
export declare class AgentTargetError extends Error {
|
|
29
|
+
readonly code: 'UNKNOWN_TARGET' | 'AMBIGUOUS_TARGET' | 'TARGET_OFFLINE';
|
|
30
|
+
constructor(code: 'UNKNOWN_TARGET' | 'AMBIGUOUS_TARGET' | 'TARGET_OFFLINE', message: string);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether an agent may type into this session. Subagents are view-only (no
|
|
34
|
+
* input). A `hwip sh` session is a real shell: an agent typing into it would
|
|
35
|
+
* run commands on that machine, on another PC as easily as on its own.
|
|
36
|
+
*/
|
|
37
|
+
export declare const isAgentTarget: (s: {
|
|
38
|
+
parentName?: string;
|
|
39
|
+
agentKind?: string;
|
|
40
|
+
}) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve `target` to one live agent session.
|
|
43
|
+
*
|
|
44
|
+
* The key `<deviceId>:<tmuxName>` is exact. Anything else is matched against
|
|
45
|
+
* the tmux name, the user's alias, the agent's own session name and the
|
|
46
|
+
* listener's label, and must match exactly one session: the same tmux name on
|
|
47
|
+
* two PCs is the routing bug a guess would reintroduce, so two matches are an
|
|
48
|
+
* error that lists both keys. The sender's own session (`selfKey`) is left out
|
|
49
|
+
* of name matching, so the same project on two PCs resolves to the other one;
|
|
50
|
+
* named alone, it resolves to itself for the caller to refuse.
|
|
51
|
+
*
|
|
52
|
+
* A session on an offline machine is refused rather than sent to: the listener
|
|
53
|
+
* takes commands over its socket only and fetches none it missed, so the
|
|
54
|
+
* message would be stored and never typed.
|
|
55
|
+
*
|
|
56
|
+
* A copy of mcp-server/src/agent-target.ts: this package depends on that one,
|
|
57
|
+
* but its entry point starts the MCP server when imported. Change both.
|
|
58
|
+
*/
|
|
59
|
+
export declare const resolveAgentTarget: (devices: AgentTargetDevice[], target: string, selfKey?: string) => AgentTarget;
|
|
60
|
+
//# sourceMappingURL=agent-target.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveAgentTarget = exports.isAgentTarget = exports.AgentTargetError = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The target named nothing, named more than one session, or is on a machine
|
|
6
|
+
* that is offline. The message lists the keys to retry with.
|
|
7
|
+
*/
|
|
8
|
+
class AgentTargetError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = 'AgentTargetError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.AgentTargetError = AgentTargetError;
|
|
17
|
+
/**
|
|
18
|
+
* Whether an agent may type into this session. Subagents are view-only (no
|
|
19
|
+
* input). A `hwip sh` session is a real shell: an agent typing into it would
|
|
20
|
+
* run commands on that machine, on another PC as easily as on its own.
|
|
21
|
+
*/
|
|
22
|
+
const isAgentTarget = (s) => !s.parentName && s.agentKind !== 'shell';
|
|
23
|
+
exports.isAgentTarget = isAgentTarget;
|
|
24
|
+
const candidatesOf = (devices) => devices.flatMap((d) => (d.agentSessions ?? [])
|
|
25
|
+
.filter(exports.isAgentTarget)
|
|
26
|
+
.map((s) => ({
|
|
27
|
+
deviceId: d.deviceId,
|
|
28
|
+
name: s.name,
|
|
29
|
+
key: `${d.deviceId}:${s.name}`,
|
|
30
|
+
alias: d.agentSessionAliases?.[s.name],
|
|
31
|
+
label: s.label,
|
|
32
|
+
providerSessionName: s.providerSessionName,
|
|
33
|
+
host: d.nickname ?? d.deviceId,
|
|
34
|
+
offline: d.isOnline === false,
|
|
35
|
+
})));
|
|
36
|
+
const listCandidates = (list) => list.map((c) => `${c.key} (${c.alias ?? c.providerSessionName ?? c.label ?? c.name} on ${c.host})`).join(', ') || 'none';
|
|
37
|
+
/**
|
|
38
|
+
* Resolve `target` to one live agent session.
|
|
39
|
+
*
|
|
40
|
+
* The key `<deviceId>:<tmuxName>` is exact. Anything else is matched against
|
|
41
|
+
* the tmux name, the user's alias, the agent's own session name and the
|
|
42
|
+
* listener's label, and must match exactly one session: the same tmux name on
|
|
43
|
+
* two PCs is the routing bug a guess would reintroduce, so two matches are an
|
|
44
|
+
* error that lists both keys. The sender's own session (`selfKey`) is left out
|
|
45
|
+
* of name matching, so the same project on two PCs resolves to the other one;
|
|
46
|
+
* named alone, it resolves to itself for the caller to refuse.
|
|
47
|
+
*
|
|
48
|
+
* A session on an offline machine is refused rather than sent to: the listener
|
|
49
|
+
* takes commands over its socket only and fetches none it missed, so the
|
|
50
|
+
* message would be stored and never typed.
|
|
51
|
+
*
|
|
52
|
+
* A copy of mcp-server/src/agent-target.ts: this package depends on that one,
|
|
53
|
+
* but its entry point starts the MCP server when imported. Change both.
|
|
54
|
+
*/
|
|
55
|
+
const resolveAgentTarget = (devices, target, selfKey) => {
|
|
56
|
+
const all = candidatesOf(devices);
|
|
57
|
+
const toTarget = ({ deviceId, name, key, host, offline }) => {
|
|
58
|
+
if (offline) {
|
|
59
|
+
throw new AgentTargetError('TARGET_OFFLINE', `${key} is on ${host}, which is offline — the message would never be typed`);
|
|
60
|
+
}
|
|
61
|
+
return { deviceId, name, key };
|
|
62
|
+
};
|
|
63
|
+
const exact = all.find((c) => c.key === target);
|
|
64
|
+
if (exact)
|
|
65
|
+
return toTarget(exact);
|
|
66
|
+
const named = (c) => [c.name, c.alias, c.providerSessionName, c.label].some((n) => n === target);
|
|
67
|
+
const others = all.filter((c) => c.key !== selfKey);
|
|
68
|
+
const matches = others.filter(named);
|
|
69
|
+
if (matches.length === 1)
|
|
70
|
+
return toTarget(matches[0]);
|
|
71
|
+
if (matches.length === 0) {
|
|
72
|
+
const self = all.find((c) => c.key === selfKey && named(c));
|
|
73
|
+
if (self)
|
|
74
|
+
return toTarget(self);
|
|
75
|
+
}
|
|
76
|
+
if (matches.length > 1) {
|
|
77
|
+
throw new AgentTargetError('AMBIGUOUS_TARGET', `ambiguous target "${target}" — use one of these keys: ${listCandidates(matches)}`);
|
|
78
|
+
}
|
|
79
|
+
throw new AgentTargetError('UNKNOWN_TARGET', `unknown target "${target}" — other agent sessions: ${listCandidates(others)}`);
|
|
80
|
+
};
|
|
81
|
+
exports.resolveAgentTarget = resolveAgentTarget;
|
package/dist/agents.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent GLOBAL skill directories, keyed by the same `id` used in
|
|
3
|
+
* `detectAgents`. Scan targets for `command-scan.ts` — keep next to the
|
|
4
|
+
* detection table so the two can never disagree about which agents exist.
|
|
5
|
+
*
|
|
6
|
+
* Global only: project-level dirs depend on a session's cwd, and the catalog is
|
|
7
|
+
* device-level (one scan per listener, shared by every session on the machine).
|
|
8
|
+
*
|
|
9
|
+
* pi's two entries come from its own docs (`docs/skills.md § Locations`,
|
|
10
|
+
* pi 0.85.1): global is `~/.pi/agent/skills/` and `~/.agents/skills/`, while
|
|
11
|
+
* `.pi/skills/` is the PROJECT dir — scanning `~/.pi/skills` found only three
|
|
12
|
+
* dangling symlinks and reported pi as having no skills at all.
|
|
13
|
+
*/
|
|
14
|
+
export declare const AGENT_SKILL_DIRS: Record<string, string[]>;
|
|
15
|
+
export interface Agent {
|
|
16
|
+
name: string;
|
|
17
|
+
id: string;
|
|
18
|
+
detected: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Absolute path of a command on PATH, or null when it isn't there.
|
|
22
|
+
* `execFileSync`, not a shell: `process.execve` needs a real path (it does no
|
|
23
|
+
* PATH lookup of its own), and building `which ${cmd}` as a shell string would
|
|
24
|
+
* let a path containing a space split into two words.
|
|
25
|
+
*/
|
|
26
|
+
export declare const resolveCommand: (cmd: string) => string | null;
|
|
27
|
+
/** The first executable `cmd` on PATH, found without spawning anything. */
|
|
28
|
+
export declare const findOnPath: (cmd: string) => string | null;
|
|
29
|
+
export declare const hasCommand: (cmd: string) => boolean;
|
|
30
|
+
export declare const detectAgents: () => Agent[];
|
|
31
|
+
//# sourceMappingURL=agents.d.ts.map
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectAgents = exports.hasCommand = exports.findOnPath = exports.resolveCommand = exports.AGENT_SKILL_DIRS = void 0;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const os_1 = require("os");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
// Agent detection shared by install / uninstall / verify. Kept in one place
|
|
9
|
+
// so the three commands can never disagree about which agents exist or how
|
|
10
|
+
// they're detected.
|
|
11
|
+
/**
|
|
12
|
+
* Per-agent GLOBAL skill directories, keyed by the same `id` used in
|
|
13
|
+
* `detectAgents`. Scan targets for `command-scan.ts` — keep next to the
|
|
14
|
+
* detection table so the two can never disagree about which agents exist.
|
|
15
|
+
*
|
|
16
|
+
* Global only: project-level dirs depend on a session's cwd, and the catalog is
|
|
17
|
+
* device-level (one scan per listener, shared by every session on the machine).
|
|
18
|
+
*
|
|
19
|
+
* pi's two entries come from its own docs (`docs/skills.md § Locations`,
|
|
20
|
+
* pi 0.85.1): global is `~/.pi/agent/skills/` and `~/.agents/skills/`, while
|
|
21
|
+
* `.pi/skills/` is the PROJECT dir — scanning `~/.pi/skills` found only three
|
|
22
|
+
* dangling symlinks and reported pi as having no skills at all.
|
|
23
|
+
*/
|
|
24
|
+
exports.AGENT_SKILL_DIRS = {
|
|
25
|
+
claude: ['.claude/skills'],
|
|
26
|
+
codex: ['.codex/skills'],
|
|
27
|
+
pi: ['.pi/agent/skills', '.agents/skills'],
|
|
28
|
+
};
|
|
29
|
+
const HOME = (0, os_1.homedir)();
|
|
30
|
+
/**
|
|
31
|
+
* Absolute path of a command on PATH, or null when it isn't there.
|
|
32
|
+
* `execFileSync`, not a shell: `process.execve` needs a real path (it does no
|
|
33
|
+
* PATH lookup of its own), and building `which ${cmd}` as a shell string would
|
|
34
|
+
* let a path containing a space split into two words.
|
|
35
|
+
*/
|
|
36
|
+
const resolveCommand = (cmd) => {
|
|
37
|
+
try {
|
|
38
|
+
return (0, child_process_1.execFileSync)('which', [cmd], { encoding: 'utf-8' }).trim() || null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Minimal images (Arch base, Fedora/RHEL minimal) ship no `which`;
|
|
42
|
+
// walking PATH ourselves answers the same question there.
|
|
43
|
+
return (0, exports.findOnPath)(cmd);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
exports.resolveCommand = resolveCommand;
|
|
47
|
+
/** The first executable `cmd` on PATH, found without spawning anything. */
|
|
48
|
+
const findOnPath = (cmd) => {
|
|
49
|
+
for (const dir of (process.env.PATH ?? '').split(path_1.delimiter)) {
|
|
50
|
+
if (!dir)
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
(0, fs_1.accessSync)((0, path_1.join)(dir, cmd), fs_1.constants.X_OK);
|
|
54
|
+
return (0, path_1.join)(dir, cmd);
|
|
55
|
+
}
|
|
56
|
+
catch { /* not in this directory */ }
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
exports.findOnPath = findOnPath;
|
|
61
|
+
const hasCommand = (cmd) => (0, exports.resolveCommand)(cmd) !== null;
|
|
62
|
+
exports.hasCommand = hasCommand;
|
|
63
|
+
const detectAgents = () => [
|
|
64
|
+
{ name: 'Claude Code', id: 'claude', detected: (0, exports.hasCommand)('claude') },
|
|
65
|
+
{ name: 'Cursor', id: 'cursor', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.cursor')) },
|
|
66
|
+
{ name: 'Windsurf', id: 'windsurf', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.codeium')) },
|
|
67
|
+
{ name: 'Gemini CLI', id: 'gemini', detected: (0, exports.hasCommand)('gemini') },
|
|
68
|
+
{ name: 'Codex CLI', id: 'codex', detected: (0, exports.hasCommand)('codex') },
|
|
69
|
+
{ name: 'Copilot CLI', id: 'copilot', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.copilot')) },
|
|
70
|
+
{ name: 'Cline', id: 'cline', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.cline')) },
|
|
71
|
+
{ name: 'Aider', id: 'aider', detected: (0, exports.hasCommand)('aider') },
|
|
72
|
+
{ name: 'Pi', id: 'pi', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.pi')) || (0, exports.hasCommand)('pi') },
|
|
73
|
+
{ name: 'OpenCode', id: 'opencode', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.config', 'opencode')) || (0, exports.hasCommand)('opencode') },
|
|
74
|
+
];
|
|
75
|
+
exports.detectAgents = detectAgents;
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two things every call to the account API from this daemon repeats:
|
|
3
|
+
* where the endpoint is, and that the request carries this machine's key
|
|
4
|
+
* and cannot outlive its budget. The callers keep their own error
|
|
5
|
+
* handling — a publish logs and returns false, a directory refresh throws
|
|
6
|
+
* — because what a failure means differs; only the convention lives here.
|
|
7
|
+
*/
|
|
8
|
+
/** `baseUrl` with its trailing slashes trimmed, plus `path`. */
|
|
9
|
+
export declare const apiUrl: (baseUrl: string, path: string) => string;
|
|
10
|
+
/**
|
|
11
|
+
* Can this API key travel in a header? It must fit the server's header limit and pass mcp-server's
|
|
12
|
+
* `fitsInHeader` rule — `fetch` drops white space at either end, and what is left must be a tab,
|
|
13
|
+
* printable ASCII or U+0080–U+00FF — so a key saved with a trailing newline still works. Checked
|
|
14
|
+
* before any `fetch`, so the key is never handed to an error that quotes it whole (C150).
|
|
15
|
+
*/
|
|
16
|
+
export declare const fitsInHeader: (key: string) => boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Authenticated request with a hard deadline. A JSON body sets its own content type.
|
|
19
|
+
* It never follows a redirect: undici drops `authorization` across origins but not
|
|
20
|
+
* `X-API-Key`, so a redirect would hand the key to whatever host it names. A refused
|
|
21
|
+
* redirect is rethrown in the words `hwip ask` and `HwipHook` use (still a `TypeError`),
|
|
22
|
+
* for callers that log what it throws — and so is a header `fetch` would not send, whose
|
|
23
|
+
* own words quote the key, and an address it would not take (not one at all, or one with a
|
|
24
|
+
* user name or password in it), whose own words quote the address; any other failure is left
|
|
25
|
+
* as `fetch` threw it.
|
|
26
|
+
*/
|
|
27
|
+
export declare const apiFetch: (fetchFn: typeof fetch, url: string, apiKey: string, timeoutMs: number, init?: RequestInit) => Promise<Response>;
|
|
28
|
+
//# sourceMappingURL=api.d.ts.map
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The two things every call to the account API from this daemon repeats:
|
|
4
|
+
* where the endpoint is, and that the request carries this machine's key
|
|
5
|
+
* and cannot outlive its budget. The callers keep their own error
|
|
6
|
+
* handling — a publish logs and returns false, a directory refresh throws
|
|
7
|
+
* — because what a failure means differs; only the convention lives here.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.apiFetch = exports.fitsInHeader = exports.apiUrl = void 0;
|
|
11
|
+
const http_failure_js_1 = require("./http-failure.js");
|
|
12
|
+
const ws_url_js_1 = require("./ws-url.js");
|
|
13
|
+
/** `baseUrl` with its trailing slashes trimmed, plus `path`. */
|
|
14
|
+
const apiUrl = (baseUrl, path) => `${baseUrl.replace(/\/+$/, '')}${path}`;
|
|
15
|
+
exports.apiUrl = apiUrl;
|
|
16
|
+
/**
|
|
17
|
+
* Can this API key travel in a header? It must fit the server's header limit and pass mcp-server's
|
|
18
|
+
* `fitsInHeader` rule — `fetch` drops white space at either end, and what is left must be a tab,
|
|
19
|
+
* printable ASCII or U+0080–U+00FF — so a key saved with a trailing newline still works. Checked
|
|
20
|
+
* before any `fetch`, so the key is never handed to an error that quotes it whole (C150).
|
|
21
|
+
*/
|
|
22
|
+
const fitsInHeader = (key) => Buffer.byteLength(key, 'utf-8') <= 8192
|
|
23
|
+
&& !/[^\t -~\u0080-ÿ]/.test(key.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, ''));
|
|
24
|
+
exports.fitsInHeader = fitsInHeader;
|
|
25
|
+
/**
|
|
26
|
+
* Authenticated request with a hard deadline. A JSON body sets its own content type.
|
|
27
|
+
* It never follows a redirect: undici drops `authorization` across origins but not
|
|
28
|
+
* `X-API-Key`, so a redirect would hand the key to whatever host it names. A refused
|
|
29
|
+
* redirect is rethrown in the words `hwip ask` and `HwipHook` use (still a `TypeError`),
|
|
30
|
+
* for callers that log what it throws — and so is a header `fetch` would not send, whose
|
|
31
|
+
* own words quote the key, and an address it would not take (not one at all, or one with a
|
|
32
|
+
* user name or password in it), whose own words quote the address; any other failure is left
|
|
33
|
+
* as `fetch` threw it.
|
|
34
|
+
*/
|
|
35
|
+
const apiFetch = async (fetchFn, url, apiKey, timeoutMs, init = {}) => {
|
|
36
|
+
// Permanent configuration problems, said before anything is tried: no `cause` is kept, as
|
|
37
|
+
// `fetch`'s own words for these quote the key or the address whole. Outside the `try`, so
|
|
38
|
+
// the `catch` below cannot rewrap them.
|
|
39
|
+
if (!(0, exports.fitsInHeader)(apiKey))
|
|
40
|
+
throw new TypeError('a header could not be sent — check the API key for stray characters');
|
|
41
|
+
if ((0, ws_url_js_1.carriesCredentials)(url))
|
|
42
|
+
throw new TypeError((0, http_failure_js_1.networkFailureMessage)(new TypeError('not used'), url));
|
|
43
|
+
try {
|
|
44
|
+
void new URL(url);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new TypeError('the base URL is not a valid address — check --base-url, HWIP_BASE_URL or "baseUrl" in ~/.hwip/config.json');
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return await fetchFn(url, {
|
|
51
|
+
...init,
|
|
52
|
+
headers: {
|
|
53
|
+
'X-API-Key': apiKey,
|
|
54
|
+
...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
55
|
+
...init.headers,
|
|
56
|
+
},
|
|
57
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
58
|
+
redirect: 'error',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// No cause kept for these: what `fetch` said is the address itself, password and all, and callers log what they catch.
|
|
63
|
+
// An address that does not parse may be one whose password has a `/`, `?` or `#` in it.
|
|
64
|
+
const quotesAddress = (0, ws_url_js_1.carriesCredentials)(url) || (0, http_failure_js_1.isInvalidUrl)(err);
|
|
65
|
+
if (err instanceof TypeError && quotesAddress)
|
|
66
|
+
throw new TypeError((0, http_failure_js_1.networkFailureMessage)(err, url));
|
|
67
|
+
// A refused redirect keeps its original: its words name no secret. An unsendable header's
|
|
68
|
+
// words quote the key whole, so its cause is dropped — the message already says what to fix.
|
|
69
|
+
if (err instanceof TypeError && (0, http_failure_js_1.isRedirectRefusal)(err))
|
|
70
|
+
throw new TypeError((0, http_failure_js_1.networkFailureMessage)(err, url), { cause: err });
|
|
71
|
+
if (err instanceof TypeError && (0, http_failure_js_1.isUnsendableHeader)(err))
|
|
72
|
+
throw new TypeError((0, http_failure_js_1.networkFailureMessage)(err, url));
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
exports.apiFetch = apiFetch;
|