@subshell-ai/plugin-claude-code 1.0.1 → 3.0.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/dist/index.d.ts +295 -33
- package/dist/index.js +221 -29
- package/icon.svg +3 -0
- package/package.json +4 -6
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region ../../plugin-api/dist/types-
|
|
1
|
+
//#region ../../plugin-api/dist/types-D6R-hjGN.d.ts
|
|
2
2
|
//#region src/types.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* The contract a Subshell plugin implements.
|
|
@@ -20,16 +20,28 @@
|
|
|
20
20
|
* For humans: it groups and labels in the UI and filters the catalog. Code
|
|
21
21
|
* branches on {@link PluginCapability} instead, so adding a type here touches
|
|
22
22
|
* labels while adding a capability touches the launch pipeline.
|
|
23
|
+
*
|
|
24
|
+
* ONE exception, and it is structural rather than a branch on a label: a
|
|
25
|
+
* `network` plugin implements {@link NetworkPlugin} instead of
|
|
26
|
+
* {@link SubshellPlugin}, so the loader picks WHICH members to require by
|
|
27
|
+
* type. A harness drives a thing in a pane; a network plugin connects this
|
|
28
|
+
* host to one network. They share the store, the manifest and the capability
|
|
29
|
+
* vocabulary, and nothing else.
|
|
23
30
|
*/
|
|
24
|
-
type PluginType = "agent-harness" | "terminal";
|
|
31
|
+
type PluginType = "agent-harness" | "terminal" | "network";
|
|
25
32
|
/**
|
|
26
33
|
* What a plugin can DO. The launch pipeline and the UI read this; neither
|
|
27
34
|
* branches on {@link PluginType}.
|
|
35
|
+
*
|
|
36
|
+
* The union is shared across types and the applicable SUBSET is not, so
|
|
37
|
+
* {@link capabilityMismatches} takes the type: a harness claiming `publish`
|
|
38
|
+
* and a network plugin claiming `resume` are both refused at load, for the
|
|
39
|
+
* same reason a claimed-but-unimplemented capability is.
|
|
28
40
|
*/
|
|
29
|
-
type PluginCapability = "mcp" | "resume" | "attention" | "settings";
|
|
30
|
-
/** A harness
|
|
31
|
-
interface
|
|
32
|
-
/** Human-friendly
|
|
41
|
+
type PluginCapability = "mcp" | "resume" | "attention" | "publish" | "supervise" | "guard" | "settings";
|
|
42
|
+
/** A harness preset as defined by the user (decoded JSON blobs). */
|
|
43
|
+
interface PresetDefinition {
|
|
44
|
+
/** Human-friendly preset name */
|
|
33
45
|
name: string;
|
|
34
46
|
/** Optional longer description */
|
|
35
47
|
description?: string | null;
|
|
@@ -39,21 +51,21 @@ interface ProfileDefinition {
|
|
|
39
51
|
flags: string[];
|
|
40
52
|
/** Settings blob passed to the harness (e.g. claude --settings JSON) */
|
|
41
53
|
settings: Record<string, unknown> | null;
|
|
42
|
-
/** If true, only this
|
|
54
|
+
/** If true, only this preset's config sources apply (isolation) */
|
|
43
55
|
configIsolation: boolean;
|
|
44
|
-
/** If true, new subshells from this
|
|
56
|
+
/** If true, new subshells from this preset auto-restart on exit */
|
|
45
57
|
restartOnExit?: boolean;
|
|
46
58
|
}
|
|
47
|
-
/** Snapshot of a single field-level validation error on a
|
|
48
|
-
interface
|
|
59
|
+
/** Snapshot of a single field-level validation error on a preset. */
|
|
60
|
+
interface PresetValidationIssue {
|
|
49
61
|
/** Field name (e.g. "name", "env", "settings") */
|
|
50
62
|
field: string;
|
|
51
63
|
/** Human-readable problem description */
|
|
52
64
|
message: string;
|
|
53
65
|
}
|
|
54
|
-
interface
|
|
66
|
+
interface PresetValidationResult {
|
|
55
67
|
valid: boolean;
|
|
56
|
-
issues:
|
|
68
|
+
issues: PresetValidationIssue[];
|
|
57
69
|
}
|
|
58
70
|
/** Everything a plugin needs to build a launch command. */
|
|
59
71
|
interface BuildCommandInput {
|
|
@@ -61,8 +73,8 @@ interface BuildCommandInput {
|
|
|
61
73
|
binary: string;
|
|
62
74
|
/** Working directory the harness runs in */
|
|
63
75
|
cwd: string;
|
|
64
|
-
/** The validated
|
|
65
|
-
|
|
76
|
+
/** The validated preset being used */
|
|
77
|
+
preset: PresetDefinition;
|
|
66
78
|
/** Subshell display name ("" = let the harness pick a default) */
|
|
67
79
|
subshellName: string;
|
|
68
80
|
/** Any additional CLI flags from route/request context */
|
|
@@ -169,12 +181,12 @@ interface McpRegistration {
|
|
|
169
181
|
args?: string[];
|
|
170
182
|
/**
|
|
171
183
|
* Extra pane env the harness needs to discover the file (e.g. OPENCODE_CONFIG).
|
|
172
|
-
* Consumed by the HOST, which bakes it into the pane env ahead of
|
|
184
|
+
* Consumed by the HOST, which bakes it into the pane env ahead of preset env;
|
|
173
185
|
* plugins never read it back.
|
|
174
186
|
*/
|
|
175
187
|
env?: Record<string, string>;
|
|
176
188
|
}
|
|
177
|
-
/** One copy-paste line shown in the
|
|
189
|
+
/** One copy-paste line shown in the preset editor for manual-setup harnesses. */
|
|
178
190
|
interface McpSetupStep {
|
|
179
191
|
/** What the user should do, and where the text goes */
|
|
180
192
|
label: string;
|
|
@@ -202,10 +214,23 @@ interface SettingsField {
|
|
|
202
214
|
label: string;
|
|
203
215
|
/** Short description for the editor */
|
|
204
216
|
description?: string;
|
|
205
|
-
/**
|
|
206
|
-
|
|
217
|
+
/**
|
|
218
|
+
* One of: string, boolean, number, select, secret.
|
|
219
|
+
*
|
|
220
|
+
* `secret` is WRITE-ONLY and network-only: the host stores it through
|
|
221
|
+
* {@link PluginHost.secrets} rather than in the settings object, reads
|
|
222
|
+
* report only whether one is set, and a plugin never receives the value —
|
|
223
|
+
* it names the secret and the host hydrates it into a process it spawns
|
|
224
|
+
* ({@link SupervisedProcessSpec}). The preset editor does not render this
|
|
225
|
+
* type; a harness declaring one is a bug rather than a second secret store.
|
|
226
|
+
*/
|
|
227
|
+
type: "string" | "boolean" | "number" | "select" | "secret";
|
|
207
228
|
/** Choices when type === "select" */
|
|
208
229
|
choices?: string[];
|
|
230
|
+
/** True when the field must be set before the plugin can act */
|
|
231
|
+
required?: boolean;
|
|
232
|
+
/** Placeholder / example, shown in the editor. Never a real credential. */
|
|
233
|
+
placeholder?: string;
|
|
209
234
|
/** Default value when unset */
|
|
210
235
|
default?: string | boolean | number;
|
|
211
236
|
}
|
|
@@ -248,6 +273,86 @@ interface PluginHost {
|
|
|
248
273
|
debug(message: string): void;
|
|
249
274
|
warn(message: string): void;
|
|
250
275
|
};
|
|
276
|
+
/**
|
|
277
|
+
* Runs a command to completion under the host's bounds and reports it.
|
|
278
|
+
*
|
|
279
|
+
* The one way a plugin executes anything. A plugin does not spawn: the host
|
|
280
|
+
* owns the deadline, the environment allowlist (the process's own
|
|
281
|
+
* environment holds the auth secret and the database path, and is never
|
|
282
|
+
* handed over), the PATH a service-run server would otherwise lack, and the
|
|
283
|
+
* output cap. Two refusals throw rather than returning a result, because
|
|
284
|
+
* both are bugs in the plugin rather than outcomes: `argv[0]` that is not an
|
|
285
|
+
* ABSOLUTE path (resolve it with {@link findBinary} — a bare name would
|
|
286
|
+
* resolve against a PATH the plugin cannot see), and `argv[0]` that is
|
|
287
|
+
* `sudo`. The server has no terminal to answer a password prompt, so a
|
|
288
|
+
* privileged command is a copy-paste instruction for the operator and never
|
|
289
|
+
* something this runs.
|
|
290
|
+
*
|
|
291
|
+
* Long-running processes are NOT this: a plugin that needs one describes it
|
|
292
|
+
* ({@link SupervisedProcessSpec}) and the host supervises it.
|
|
293
|
+
* @since apiVersion 2
|
|
294
|
+
*/
|
|
295
|
+
run(argv: string[], opts?: RunOptions): Promise<RunResult>;
|
|
296
|
+
/**
|
|
297
|
+
* Write-only credential storage, scoped to this plugin.
|
|
298
|
+
*
|
|
299
|
+
* There is deliberately NO `get`. A plugin that could read a credential
|
|
300
|
+
* could put it in an argv (visible in `ps`), a log line or a hint string;
|
|
301
|
+
* every legitimate consumer is a process the HOST spawns, so the host
|
|
302
|
+
* hydrates the value itself from the name the plugin gives it. Values are
|
|
303
|
+
* stored 0600 under the host's data directory, never in the database and
|
|
304
|
+
* never in a settings row.
|
|
305
|
+
* @since apiVersion 2
|
|
306
|
+
*/
|
|
307
|
+
secrets: PluginSecrets;
|
|
308
|
+
/** The operating system this host runs on. */
|
|
309
|
+
readonly platform: PluginPlatform;
|
|
310
|
+
/** The home directory of the user this host runs as. */
|
|
311
|
+
readonly homeDir: string;
|
|
312
|
+
}
|
|
313
|
+
/** Where a plugin can be driven at all. Mirrors the manifest's `platforms`. */
|
|
314
|
+
type PluginPlatform = "darwin" | "linux";
|
|
315
|
+
/** Options for {@link PluginHost.run}. */
|
|
316
|
+
interface RunOptions {
|
|
317
|
+
/** Deadline in milliseconds. Default 30_000; the host caps it at 10 minutes. */
|
|
318
|
+
timeoutMs?: number;
|
|
319
|
+
/** Called per output line, ANSI already stripped, from both streams interleaved. */
|
|
320
|
+
onLine?: (line: string) => void;
|
|
321
|
+
/**
|
|
322
|
+
* Ends the run early without failing it.
|
|
323
|
+
*
|
|
324
|
+
* What an interactive login needs: `tailscale up` with no key blocks until a
|
|
325
|
+
* human finishes in a browser, having already printed the URL — so the
|
|
326
|
+
* plugin reads the URL off {@link onLine} and aborts, leaving the daemon
|
|
327
|
+
* waiting. An aborted run reports `aborted: true` and whatever was captured.
|
|
328
|
+
*/
|
|
329
|
+
signal?: AbortSignal;
|
|
330
|
+
/** Extra environment on top of the host's allowlist. Never overrides PATH. */
|
|
331
|
+
env?: Record<string, string>;
|
|
332
|
+
/** Text to write to stdin. Absent means stdin is /dev/null, so a prompt cannot hang forever. */
|
|
333
|
+
stdin?: string;
|
|
334
|
+
}
|
|
335
|
+
/** What {@link PluginHost.run} reports. Never throws for a non-zero exit. */
|
|
336
|
+
interface RunResult {
|
|
337
|
+
/** Exit code, or null when the process was signalled or never started. */
|
|
338
|
+
code: number | null;
|
|
339
|
+
/** Captured stdout, ANSI stripped, capped. */
|
|
340
|
+
stdout: string;
|
|
341
|
+
/** Captured stderr, ANSI stripped, capped. */
|
|
342
|
+
stderr: string;
|
|
343
|
+
/** True when the deadline ended it. */
|
|
344
|
+
timedOut: boolean;
|
|
345
|
+
/** True when {@link RunOptions.signal} ended it. */
|
|
346
|
+
aborted: boolean;
|
|
347
|
+
}
|
|
348
|
+
/** Write-only, per-plugin credential storage. See {@link PluginHost.secrets}. */
|
|
349
|
+
interface PluginSecrets {
|
|
350
|
+
/** Stores (or replaces) a credential under this name. */
|
|
351
|
+
set(name: string, value: string): Promise<void>;
|
|
352
|
+
/** Whether a credential is stored under this name. The only read there is. */
|
|
353
|
+
has(name: string): Promise<boolean>;
|
|
354
|
+
/** Removes it. Absent is not an error. */
|
|
355
|
+
delete(name: string): Promise<void>;
|
|
251
356
|
}
|
|
252
357
|
/**
|
|
253
358
|
* The plugin itself.
|
|
@@ -259,8 +364,8 @@ interface PluginHost {
|
|
|
259
364
|
interface SubshellPlugin {
|
|
260
365
|
/** Builds the argv (no shell) used to launch a subshell. */
|
|
261
366
|
buildCommand(input: BuildCommandInput): string[];
|
|
262
|
-
/** Validates a
|
|
263
|
-
|
|
367
|
+
/** Validates a preset definition before saving. */
|
|
368
|
+
validatePreset(preset: PresetDefinition): PresetValidationResult;
|
|
264
369
|
/** Which optional members below are meaningful on this plugin. */
|
|
265
370
|
capabilities(): PluginCapability[];
|
|
266
371
|
/** Maps a harness exit code to a human label (null = unknown). */
|
|
@@ -292,21 +397,30 @@ interface SubshellPlugin {
|
|
|
292
397
|
mcpRegistration?(launch: McpLaunchSpec, configPath: string): McpRegistration;
|
|
293
398
|
/** How users obtain the subshell MCP tools here. Declare the `mcp` capability with it. */
|
|
294
399
|
mcpSetup?(launch: McpLaunchSpec): McpSetupInfo;
|
|
295
|
-
/** Settings rendered in the
|
|
296
|
-
|
|
297
|
-
/** Known extra env var suggestions for the
|
|
400
|
+
/** Settings rendered in the PRESET editor and stored on the preset. */
|
|
401
|
+
presetSettings?(): SettingsField[];
|
|
402
|
+
/** Known extra env var suggestions for the preset editor. */
|
|
298
403
|
suggestedEnv?(): {
|
|
299
404
|
key: string;
|
|
300
405
|
description: string;
|
|
301
406
|
}[];
|
|
302
|
-
/** Known CLI flag suggestions for the
|
|
407
|
+
/** Known CLI flag suggestions for the preset editor. */
|
|
303
408
|
suggestedFlags?(): {
|
|
304
409
|
flag: string;
|
|
305
410
|
description: string;
|
|
306
411
|
}[];
|
|
307
412
|
}
|
|
308
|
-
/**
|
|
309
|
-
|
|
413
|
+
/**
|
|
414
|
+
* A factory that returns a harness, for a plugin that knows which it is.
|
|
415
|
+
*
|
|
416
|
+
* {@link PluginFactory} is the LOADER's view — it must accept either shape,
|
|
417
|
+
* because it decides which to expect from the manifest. A plugin itself has no
|
|
418
|
+
* such ambiguity, and annotating its own factory with the union would widen
|
|
419
|
+
* what its tests can call: `createPlugin(host).buildCommand(...)` stops
|
|
420
|
+
* type-checking on a value that might be a network plugin. So a plugin
|
|
421
|
+
* declares the half it implements and the loader keeps the union.
|
|
422
|
+
*/
|
|
423
|
+
type HarnessPluginFactory = (host: PluginHost) => SubshellPlugin;
|
|
310
424
|
//#endregion
|
|
311
425
|
//#region ../../plugin-api/dist/index.d.ts
|
|
312
426
|
/** How a host finds this plugin's binary, as data, so a scan loads no plugin code. */
|
|
@@ -315,16 +429,158 @@ interface DetectSpec {
|
|
|
315
429
|
binaryName: string;
|
|
316
430
|
/** Env var that overrides the lookup outright, e.g. "CLAUDE_PATH" */
|
|
317
431
|
envOverride: string;
|
|
318
|
-
/**
|
|
432
|
+
/**
|
|
433
|
+
* Well-known locations, tried after PATH and before the version managers.
|
|
434
|
+
*
|
|
435
|
+
* HOME-relative (`.local/bin/mytool`), or ABSOLUTE when the entry starts
|
|
436
|
+
* with `/` — which is how a vendor's GUI install is named, since
|
|
437
|
+
* `/Applications/Tailscale.app/Contents/MacOS/Tailscale` is nobody's HOME.
|
|
438
|
+
* Each is a candidate to search, so an entry that does not resolve costs
|
|
439
|
+
* nothing and the ladder carries on.
|
|
440
|
+
*/
|
|
319
441
|
knownPaths: string[];
|
|
320
442
|
}
|
|
321
443
|
/** Where to send someone whose binary is missing. */
|
|
322
444
|
interface InstallSpec {
|
|
323
|
-
/**
|
|
445
|
+
/**
|
|
446
|
+
* Copy-pasteable install command for the official installer.
|
|
447
|
+
*
|
|
448
|
+
* A command the HOST may run on request, which is why it may not be
|
|
449
|
+
* privileged: `sudo`, `doas` and `pkexec` are refused by the parser at any
|
|
450
|
+
* command boundary (the line is run through a shell — see
|
|
451
|
+
* {@link needsPrivileges}), so an installer that needs root is described
|
|
452
|
+
* under {@link NetworkManifest.privileged} instead and is only ever shown to
|
|
453
|
+
* copy. Without that refusal the two would be one field with two meanings,
|
|
454
|
+
* and the surface offering a button would have to guess.
|
|
455
|
+
*/
|
|
324
456
|
command: string;
|
|
325
|
-
/**
|
|
457
|
+
/**
|
|
458
|
+
* URL of the installation documentation.
|
|
459
|
+
*
|
|
460
|
+
* Rendered as a link on an admin page, so it must be `http:` or `https:`
|
|
461
|
+
* and the parser refuses anything else — see {@link isDocsUrl}.
|
|
462
|
+
*/
|
|
326
463
|
docsUrl: string;
|
|
327
464
|
}
|
|
465
|
+
/** One step an operator must run themselves, because the host may not. */
|
|
466
|
+
interface PrivilegedStep {
|
|
467
|
+
/** What it does, in a few words: "Install the daemon" */
|
|
468
|
+
label: string;
|
|
469
|
+
/** The command to copy. Usually `sudo …`. */
|
|
470
|
+
command: string;
|
|
471
|
+
/** Where the vendor documents it; `http:`/`https:` only, as {@link isDocsUrl} requires. */
|
|
472
|
+
docsUrl?: string;
|
|
473
|
+
/**
|
|
474
|
+
* Which ALTERNATIVE this step belongs to, and the heading it renders under.
|
|
475
|
+
*
|
|
476
|
+
* Steps sharing a group are one sequence to run in order; different groups
|
|
477
|
+
* are different ways to arrive at the same place, and a surface renders an
|
|
478
|
+
* `or` between them rather than continuing the numbering. Steps with no
|
|
479
|
+
* group are one plain sequence, which is what every step was before this
|
|
480
|
+
* existed.
|
|
481
|
+
*
|
|
482
|
+
* It exists because a platform can genuinely offer two routes: on macOS
|
|
483
|
+
* Tailscale ships both a GUI app and a command-line daemon, and a page that
|
|
484
|
+
* numbered those 1..3 told a person to install both. Non-empty when present
|
|
485
|
+
* — it is a heading, so `""` is refused rather than coerced.
|
|
486
|
+
*/
|
|
487
|
+
group?: string;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* The `subshell.network` block. Required when `type` is `network`.
|
|
491
|
+
*
|
|
492
|
+
* Everything here is DATA a host reads without loading plugin code: which
|
|
493
|
+
* machines this plugin can run on at all, whether it can produce a login URL,
|
|
494
|
+
* what publishing it exposes, and which steps only a human with root can
|
|
495
|
+
* perform. A page can therefore say "not available on this platform" or print
|
|
496
|
+
* the two sudo commands before any plugin code has been imported — and before
|
|
497
|
+
* the vendor's CLI is anywhere on the machine.
|
|
498
|
+
*/
|
|
499
|
+
interface NetworkManifest {
|
|
500
|
+
/**
|
|
501
|
+
* Operating systems this plugin can be driven on.
|
|
502
|
+
*
|
|
503
|
+
* Not a hint: a host refuses every act on a platform absent from this list,
|
|
504
|
+
* and the UI renders the row as unavailable rather than offering a button
|
|
505
|
+
* that would 409. Vendors differ here for real reasons — a CLI that talks to
|
|
506
|
+
* a logged-in desktop session cannot be driven from a launchd service.
|
|
507
|
+
*/
|
|
508
|
+
platforms: PluginPlatform[];
|
|
509
|
+
/**
|
|
510
|
+
* True when `join({})` with no credential can yield a URL a human finishes.
|
|
511
|
+
*
|
|
512
|
+
* Data rather than a capability because it describes what a RETURN VALUE may
|
|
513
|
+
* be, and a capability is checked against members. The UI reads it to decide
|
|
514
|
+
* whether to offer "sign in" beside "paste a key".
|
|
515
|
+
*/
|
|
516
|
+
interactiveLogin?: boolean;
|
|
517
|
+
/**
|
|
518
|
+
* True when publishing leaves NOTHING the daemon can later be asked about.
|
|
519
|
+
*
|
|
520
|
+
* NetBird is the case that forced the field: a join is the whole publish,
|
|
521
|
+
* so `status()` can only ever see the join, and a plugin that declared
|
|
522
|
+
* itself `published` would claim a thing its own vendor CLI never told it.
|
|
523
|
+
* The HOST's publish record is the one witness of the transition, and this
|
|
524
|
+
* flag is what lets the host (and only the host) upgrade `joined` to
|
|
525
|
+
* `published` from it. A plugin without the flag is never upgraded: for
|
|
526
|
+
* Tailscale a serve reset outside this app is a real state, and `joined`
|
|
527
|
+
* is the honest reading of it.
|
|
528
|
+
*/
|
|
529
|
+
publishImplicit?: boolean;
|
|
530
|
+
/**
|
|
531
|
+
* What publishing on this network exposes the server to.
|
|
532
|
+
*
|
|
533
|
+
* `private` is a network only invited machines are on. `public-with-gate`
|
|
534
|
+
* reaches the open internet with an identity check in front — a different
|
|
535
|
+
* security posture, stated here so every surface can say so before the act
|
|
536
|
+
* rather than after it.
|
|
537
|
+
*/
|
|
538
|
+
exposure: "private" | "public-with-gate";
|
|
539
|
+
/**
|
|
540
|
+
* Steps the host can never perform, per platform, in the order to run them.
|
|
541
|
+
*
|
|
542
|
+
* Every mesh VPN installs a daemon as root and the server has no terminal to
|
|
543
|
+
* answer a password prompt, so these are printed to copy and never executed.
|
|
544
|
+
* Keeping them out of {@link InstallSpec} is what lets a surface offer a
|
|
545
|
+
* button for one and a copy row for the other without inspecting the string.
|
|
546
|
+
*/
|
|
547
|
+
privileged?: Partial<Record<PluginPlatform, PrivilegedStep[]>>;
|
|
548
|
+
/**
|
|
549
|
+
* What this network calls the two things a person acts on.
|
|
550
|
+
*
|
|
551
|
+
* Vendors name the same act differently and a generic word is wrong rather
|
|
552
|
+
* than merely bland: Tailscale takes an "auth key", NetBird a "setup key",
|
|
553
|
+
* Cloudflare a "tunnel token", and a field labelled "Auth key" on the
|
|
554
|
+
* NetBird row asks for something NetBird does not have. Likewise publishing
|
|
555
|
+
* is "Tailscale Serve" on one network and starting a tunnel on another.
|
|
556
|
+
*
|
|
557
|
+
* MANIFEST data rather than something the plugin returns, for the same
|
|
558
|
+
* reason `platforms` is: a surface renders these before any plugin code has
|
|
559
|
+
* been loaded, and before the vendor's CLI is anywhere on the machine. Both
|
|
560
|
+
* are optional and both have a sensible generic default, so a plugin that
|
|
561
|
+
* says nothing is merely plain rather than broken.
|
|
562
|
+
*/
|
|
563
|
+
labels?: NetworkLabels;
|
|
564
|
+
}
|
|
565
|
+
/** The vendor's own words for the two acts a person takes. See {@link NetworkManifest.labels}. */
|
|
566
|
+
interface NetworkLabels {
|
|
567
|
+
/** What to call the pasted credential: "Auth key", "Setup key", "Tunnel token". */
|
|
568
|
+
credential?: string;
|
|
569
|
+
/** What to call publishing: "Publish with Tailscale Serve", "Start tunnel". */
|
|
570
|
+
publish?: string;
|
|
571
|
+
/**
|
|
572
|
+
* Where the credential named by `credential` comes from — the vendor page
|
|
573
|
+
* that mints one.
|
|
574
|
+
*
|
|
575
|
+
* The credential box asks for an "Auth key" on a card that otherwise says
|
|
576
|
+
* nothing about what an auth key is or where to get it, and every vendor
|
|
577
|
+
* words that differently enough that the SPA cannot write the sentence
|
|
578
|
+
* itself. So the plugin names its page, and the card renders it as a Docs
|
|
579
|
+
* link on the label row. http(s) only, refused at parse like every other
|
|
580
|
+
* URL this contract carries — it lands in an `href` on an admin page.
|
|
581
|
+
*/
|
|
582
|
+
credentialDocsUrl?: string;
|
|
583
|
+
}
|
|
328
584
|
/** The parsed `subshell` block. */
|
|
329
585
|
interface SubshellManifest {
|
|
330
586
|
/** plugin-api version this plugin was built against */
|
|
@@ -337,7 +593,11 @@ interface SubshellManifest {
|
|
|
337
593
|
name: string;
|
|
338
594
|
/** One-line description shown in the UI */
|
|
339
595
|
description: string;
|
|
340
|
-
/**
|
|
596
|
+
/**
|
|
597
|
+
* Relative path to this plugin's icon file, e.g. `icon.svg`. Served by
|
|
598
|
+
* the control plane at `/api/plugins/<id>/icon`; absent means the UI
|
|
599
|
+
* renders a monogram instead.
|
|
600
|
+
*/
|
|
341
601
|
icon?: string;
|
|
342
602
|
/** Module to import, relative to the package directory */
|
|
343
603
|
entry: string;
|
|
@@ -355,6 +615,8 @@ interface SubshellManifest {
|
|
|
355
615
|
* fallback, i.e. a resume that quietly never offers itself (§11).
|
|
356
616
|
*/
|
|
357
617
|
hostEnv?: string[];
|
|
618
|
+
/** Network facts. Present exactly when `type` is `network`. */
|
|
619
|
+
network?: NetworkManifest;
|
|
358
620
|
}
|
|
359
621
|
//#endregion
|
|
360
622
|
//#region src/manifest.d.ts
|
|
@@ -365,9 +627,9 @@ declare const manifest: SubshellManifest;
|
|
|
365
627
|
* Built-in harness: Claude Code.
|
|
366
628
|
*
|
|
367
629
|
* Launch shape:
|
|
368
|
-
* claude --settings <json> --name <subshell> [
|
|
630
|
+
* claude --settings <json> --name <subshell> [preset flags] [extra flags]
|
|
369
631
|
* Settings are passed as a JSON string via `--settings` (per-invocation, so
|
|
370
|
-
*
|
|
632
|
+
* preset config never needs to write into the user's real ~/.claude).
|
|
371
633
|
*
|
|
372
634
|
* Identity, detection and install guidance live in this package's
|
|
373
635
|
* package.json `subshell` block, NOT here: the host reads them without
|
|
@@ -378,6 +640,6 @@ declare const manifest: SubshellManifest;
|
|
|
378
640
|
* version probe, shell quoting, a namespaced logger). See
|
|
379
641
|
* `@subshell-ai/plugin-api`.
|
|
380
642
|
*/
|
|
381
|
-
declare const createPlugin:
|
|
643
|
+
declare const createPlugin: HarnessPluginFactory;
|
|
382
644
|
//#endregion
|
|
383
645
|
export { createPlugin as default, manifest };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
-
//#region ../../plugin-api/dist/manifest-
|
|
2
|
+
//#region ../../plugin-api/dist/manifest-DKMceUPh.js
|
|
3
3
|
/** Every {@link PluginType}, for anything that has to iterate or validate them. */
|
|
4
|
-
const PLUGIN_TYPES = [
|
|
4
|
+
const PLUGIN_TYPES = [
|
|
5
|
+
"agent-harness",
|
|
6
|
+
"terminal",
|
|
7
|
+
"network"
|
|
8
|
+
];
|
|
9
|
+
/** Every {@link PluginPlatform}, for validation. */
|
|
10
|
+
const PLUGIN_PLATFORMS = ["darwin", "linux"];
|
|
5
11
|
/**
|
|
6
12
|
* The MCP server name every harness registers the built-in `subshell mcp`
|
|
7
13
|
* server under: the config object key, and the `hermes mcp add|remove`
|
|
@@ -10,6 +16,17 @@ const PLUGIN_TYPES = ["agent-harness", "terminal"];
|
|
|
10
16
|
*/
|
|
11
17
|
const MCP_SERVER_NAME = "subshell";
|
|
12
18
|
/**
|
|
19
|
+
* Image formats a plugin icon may be. The server maps these to a Content-Type
|
|
20
|
+
* from a fixed table rather than sniffing the file, so this list and that
|
|
21
|
+
* table are one decision: adding a format here without adding it there leaves
|
|
22
|
+
* an icon that parses and then 500s when something asks for it.
|
|
23
|
+
*/
|
|
24
|
+
const ICON_EXTENSIONS = [
|
|
25
|
+
".svg",
|
|
26
|
+
".png",
|
|
27
|
+
".webp"
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
13
30
|
* Ids become directory names under `<dataDir>/plugins/`, so they are path
|
|
14
31
|
* segments and nothing else: no separators, no traversal, no surprises from a
|
|
15
32
|
* case-insensitive filesystem.
|
|
@@ -18,6 +35,80 @@ const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
|
18
35
|
function isRecord(value) {
|
|
19
36
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
37
|
}
|
|
38
|
+
/** The privilege wrappers no install command may ever run, by first-word basename. */
|
|
39
|
+
const PRIVILEGED_COMMANDS = [
|
|
40
|
+
"sudo",
|
|
41
|
+
"doas",
|
|
42
|
+
"pkexec"
|
|
43
|
+
];
|
|
44
|
+
/** Shell boundaries that introduce a NEW command word: `;` `|` `&` (so `&&` and `||` too) and newline. */
|
|
45
|
+
const COMMAND_BOUNDARY_RE = /[;&|\n]+/;
|
|
46
|
+
/** A leading `NAME=…` env assignment, which `sh` strips before choosing the command. */
|
|
47
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s*/;
|
|
48
|
+
/**
|
|
49
|
+
* Whether a shell line runs one of {@link PRIVILEGED_COMMANDS} AS A COMMAND
|
|
50
|
+
* anywhere in it — not merely at the start.
|
|
51
|
+
*
|
|
52
|
+
* `install.command` is the one manifest field a host RUNS, and it runs through
|
|
53
|
+
* `sh -c` (`api/network/install-network.route.ts`), which means any boundary
|
|
54
|
+
* in the line can start the privileged command the start of the line hides:
|
|
55
|
+
* `"apt-get update && sudo apt-get install -y x"` is sudo-without-prompt on a
|
|
56
|
+
* NOPASSWD host. A line-start regex was the first gate and it was not one.
|
|
57
|
+
*
|
|
58
|
+
* The split is CONSERVATIVE, not a shell parser: segments are taken at every
|
|
59
|
+
* boundary character quote-blind (so a `;` inside a quoted string still
|
|
60
|
+
* counts, which can only over-refuse), leading env assignments are stripped
|
|
61
|
+
* (`FOO=bar sudo x` runs sudo), and each segment's first word is compared by
|
|
62
|
+
* BASENAME (`/usr/bin/sudo` is sudo, `pkexec-demo` is not). A word merely
|
|
63
|
+
* CONTAINING the token is fine — the comparison is equality on the basename.
|
|
64
|
+
* The accepted limit is a wrapper hidden inside quotes (`sh -c 'sudo …'`),
|
|
65
|
+
* which is the same limit `PluginHost.run`'s first-word check has, and it is
|
|
66
|
+
* accepted for the same reason: a plugin whose code LOADS can already run
|
|
67
|
+
* anything in this process (`docs/security.md` §11.9) — this rule refuses
|
|
68
|
+
* careless manifests deterministically, it is not a sandbox.
|
|
69
|
+
*/
|
|
70
|
+
function needsPrivileges(command) {
|
|
71
|
+
for (const segment of command.split(COMMAND_BOUNDARY_RE)) {
|
|
72
|
+
let rest = segment.trim();
|
|
73
|
+
while (ENV_ASSIGNMENT_RE.test(rest)) rest = rest.replace(ENV_ASSIGNMENT_RE, "");
|
|
74
|
+
if (rest === "") continue;
|
|
75
|
+
const ws = rest.search(/\s/);
|
|
76
|
+
const firstWord = ws === -1 ? rest : rest.slice(0, ws);
|
|
77
|
+
const base = firstWord.slice(firstWord.lastIndexOf("/") + 1);
|
|
78
|
+
if (PRIVILEGED_COMMANDS.includes(base)) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether a string may be rendered as a link in an operator's browser.
|
|
84
|
+
*
|
|
85
|
+
* Every `docsUrl` in this contract ends up as the `href` of an anchor on an
|
|
86
|
+
* admin page, and an `href` is not inert: a `javascript:` URL in one is script
|
|
87
|
+
* running on the control plane's own origin, in the session of the one person
|
|
88
|
+
* who can install plugins. So a URL this contract carries is restricted to the
|
|
89
|
+
* two schemes that navigate, and both the parser below and the host check it.
|
|
90
|
+
*
|
|
91
|
+
* It is checked in two places on purpose. A manifest is static data, so a bad
|
|
92
|
+
* value there is a plugin defect and is refused at load. A URL a plugin
|
|
93
|
+
* REPORTS at runtime is often something it read off a vendor CLI — Tailscale's
|
|
94
|
+
* `AuthURL` comes from whichever control server the operator pointed the
|
|
95
|
+
* daemon at — so the host drops that one instead, because refusing to load a
|
|
96
|
+
* working plugin over a value its control server chose would be the wrong
|
|
97
|
+
* failure.
|
|
98
|
+
*
|
|
99
|
+
* Not a reachability claim: nothing fetches these, and a docs page that 404s
|
|
100
|
+
* is a broken link rather than a hazard.
|
|
101
|
+
* @param value - the candidate URL, as written in a manifest or returned by a plugin
|
|
102
|
+
*/
|
|
103
|
+
function isDocsUrl(value) {
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = new URL(value);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
111
|
+
}
|
|
21
112
|
/**
|
|
22
113
|
* Reads and validates the `subshell` block of a plugin's package.json.
|
|
23
114
|
*
|
|
@@ -32,13 +123,18 @@ function parseManifest(pkgJson) {
|
|
|
32
123
|
const block = pkgJson.subshell;
|
|
33
124
|
if (!isRecord(block)) return { error: "package.json has no `subshell` block, so it is not a Subshell plugin" };
|
|
34
125
|
if (typeof block.apiVersion !== "number" || !Number.isInteger(block.apiVersion)) return { error: "`subshell.apiVersion` must be an integer" };
|
|
35
|
-
if (block.apiVersion < 1) return { error: `this plugin declares plugin-api ${block.apiVersion}, which is not a version; the lowest is 1 and this host implements
|
|
36
|
-
if (block.apiVersion >
|
|
126
|
+
if (block.apiVersion < 1) return { error: `this plugin declares plugin-api ${block.apiVersion}, which is not a version; the lowest is 1 and this host implements 2` };
|
|
127
|
+
if (block.apiVersion > 2) return { error: `this plugin needs plugin-api ${block.apiVersion}, but this host implements 2; upgrade Subshell` };
|
|
37
128
|
if (typeof block.id !== "string" || !ID_RE.test(block.id)) return { error: "`subshell.id` must be lowercase letters, digits and hyphens, starting alphanumeric (it becomes a directory name)" };
|
|
38
129
|
if (typeof block.type !== "string" || !PLUGIN_TYPES.includes(block.type)) return { error: `\`subshell.type\` must be one of: ${PLUGIN_TYPES.join(", ")}` };
|
|
39
130
|
if (typeof block.name !== "string" || block.name.trim() === "") return { error: "`subshell.name` must be a non-empty string" };
|
|
40
131
|
if (typeof block.description !== "string") return { error: "`subshell.description` must be a string" };
|
|
41
|
-
if (block.icon !== void 0
|
|
132
|
+
if (block.icon !== void 0) {
|
|
133
|
+
if (typeof block.icon !== "string" || block.icon.trim() === "") return { error: "`subshell.icon` must be a non-empty relative path to an image in the package" };
|
|
134
|
+
if (block.icon.startsWith("/") || block.icon.split("/").includes("..")) return { error: "`subshell.icon` must stay inside the package (no leading `/` and no `..` segment)" };
|
|
135
|
+
const icon = block.icon;
|
|
136
|
+
if (!ICON_EXTENSIONS.some((ext) => icon.endsWith(ext))) return { error: `\`subshell.icon\` must end in one of: ${ICON_EXTENSIONS.join(", ")}` };
|
|
137
|
+
}
|
|
42
138
|
if (typeof block.entry !== "string" || block.entry.trim() === "") return { error: "`subshell.entry` must be a non-empty relative path" };
|
|
43
139
|
if (block.entry.startsWith("/") || block.entry.split("/").includes("..")) return { error: "`subshell.entry` must stay inside the package (no leading `/` and no `..` segment)" };
|
|
44
140
|
let detect;
|
|
@@ -55,6 +151,8 @@ function parseManifest(pkgJson) {
|
|
|
55
151
|
if (block.install !== void 0) {
|
|
56
152
|
const i = block.install;
|
|
57
153
|
if (!isRecord(i) || typeof i.command !== "string" || typeof i.docsUrl !== "string") return { error: "`subshell.install` needs a command and a docsUrl" };
|
|
154
|
+
if (needsPrivileges(i.command)) return { error: "`subshell.install.command` must not need sudo, doas or pkexec — the host runs it through a shell and has no terminal for a password prompt; put privileged steps in `subshell.network.privileged`" };
|
|
155
|
+
if (!isDocsUrl(i.docsUrl)) return { error: "`subshell.install.docsUrl` must be an http(s) URL — it is rendered as a link on an admin page" };
|
|
58
156
|
install = {
|
|
59
157
|
command: i.command,
|
|
60
158
|
docsUrl: i.docsUrl
|
|
@@ -65,39 +163,106 @@ function parseManifest(pkgJson) {
|
|
|
65
163
|
if (!Array.isArray(block.hostEnv) || !block.hostEnv.every((k) => typeof k === "string" && k.trim() !== "")) return { error: "`subshell.hostEnv` must be an array of environment variable names" };
|
|
66
164
|
hostEnv = [...block.hostEnv];
|
|
67
165
|
}
|
|
166
|
+
const type = block.type;
|
|
167
|
+
const parsedNetwork = parseNetworkBlock(block.network, type);
|
|
168
|
+
if (parsedNetwork !== void 0 && "error" in parsedNetwork) return parsedNetwork;
|
|
68
169
|
return {
|
|
69
170
|
apiVersion: block.apiVersion,
|
|
70
171
|
id: block.id,
|
|
71
|
-
type
|
|
172
|
+
type,
|
|
72
173
|
name: block.name,
|
|
73
174
|
description: block.description,
|
|
74
175
|
...typeof block.icon === "string" ? { icon: block.icon } : {},
|
|
75
176
|
entry: block.entry,
|
|
76
177
|
...detect ? { detect } : {},
|
|
77
178
|
...install ? { install } : {},
|
|
78
|
-
...hostEnv ? { hostEnv } : {}
|
|
179
|
+
...hostEnv ? { hostEnv } : {},
|
|
180
|
+
...parsedNetwork ? { network: parsedNetwork } : {}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Parses `subshell.network`, which is required for and exclusive to `network`.
|
|
185
|
+
*
|
|
186
|
+
* Both directions are refused rather than tolerated. A network plugin without
|
|
187
|
+
* the block would be unrenderable — nothing could say which platforms it runs
|
|
188
|
+
* on or what publishing it exposes, and the safe default for "what does this
|
|
189
|
+
* expose" is not a default anyone should pick silently. A harness WITH one
|
|
190
|
+
* declares facts nothing reads, which is how a manifest starts lying.
|
|
191
|
+
* @returns the parsed block, an error, or undefined when there is none to have
|
|
192
|
+
*/
|
|
193
|
+
function parseNetworkBlock(raw, type) {
|
|
194
|
+
if (type !== "network") {
|
|
195
|
+
if (raw !== void 0) return { error: `\`subshell.network\` is only for \`type: "network"\` plugins, and this one is \`${type}\`` };
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!isRecord(raw)) return { error: "`subshell.network` is required for `type: \"network\"` plugins" };
|
|
199
|
+
if (!Array.isArray(raw.platforms) || raw.platforms.length === 0 || !raw.platforms.every((p) => typeof p === "string" && PLUGIN_PLATFORMS.includes(p))) return { error: `\`subshell.network.platforms\` must be a non-empty array of: ${PLUGIN_PLATFORMS.join(", ")}` };
|
|
200
|
+
if (raw.exposure !== "private" && raw.exposure !== "public-with-gate") return { error: "`subshell.network.exposure` must be \"private\" or \"public-with-gate\"" };
|
|
201
|
+
if (raw.interactiveLogin !== void 0 && typeof raw.interactiveLogin !== "boolean") return { error: "`subshell.network.interactiveLogin` must be a boolean" };
|
|
202
|
+
if (raw.publishImplicit !== void 0 && typeof raw.publishImplicit !== "boolean") return { error: "`subshell.network.publishImplicit` must be a boolean" };
|
|
203
|
+
let privileged;
|
|
204
|
+
if (raw.privileged !== void 0) {
|
|
205
|
+
if (!isRecord(raw.privileged)) return { error: "`subshell.network.privileged` must be an object keyed by platform" };
|
|
206
|
+
privileged = {};
|
|
207
|
+
for (const [platform, steps] of Object.entries(raw.privileged)) {
|
|
208
|
+
if (!PLUGIN_PLATFORMS.includes(platform)) return { error: `\`subshell.network.privileged\` has an unknown platform "${platform}"` };
|
|
209
|
+
if (!Array.isArray(steps) || !steps.every((s) => isRecord(s) && typeof s.label === "string" && s.label.trim() !== "" && typeof s.command === "string" && s.command.trim() !== "" && (s.docsUrl === void 0 || typeof s.docsUrl === "string") && (s.group === void 0 || typeof s.group === "string"))) return { error: `\`subshell.network.privileged.${platform}\` must be an array of { label, command, docsUrl?, group? }` };
|
|
210
|
+
const badGroup = steps.find((s) => s.group !== void 0 && s.group.trim() === "");
|
|
211
|
+
if (badGroup) return { error: `\`subshell.network.privileged.${platform}\` has an empty \`group\`, which is a heading with no words: "${badGroup.label}"` };
|
|
212
|
+
const badDocs = steps.find((s) => s.docsUrl !== void 0 && !isDocsUrl(s.docsUrl));
|
|
213
|
+
if (badDocs) return { error: `\`subshell.network.privileged.${platform}\` has a docsUrl that is not an http(s) URL: "${badDocs.label}"` };
|
|
214
|
+
privileged[platform] = steps.map((s) => ({
|
|
215
|
+
label: s.label,
|
|
216
|
+
command: s.command,
|
|
217
|
+
...s.docsUrl ? { docsUrl: s.docsUrl } : {},
|
|
218
|
+
...s.group ? { group: s.group } : {}
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
let labels;
|
|
223
|
+
if (raw.labels !== void 0) {
|
|
224
|
+
if (!isRecord(raw.labels)) return { error: "`subshell.network.labels` must be an object" };
|
|
225
|
+
for (const key of ["credential", "publish"]) {
|
|
226
|
+
const value = raw.labels[key];
|
|
227
|
+
if (value !== void 0 && (typeof value !== "string" || value.trim() === "")) return { error: `\`subshell.network.labels.${key}\` must be a non-empty string` };
|
|
228
|
+
}
|
|
229
|
+
const credDocs = raw.labels.credentialDocsUrl;
|
|
230
|
+
if (credDocs !== void 0 && (typeof credDocs !== "string" || !isDocsUrl(credDocs))) return { error: "`subshell.network.labels.credentialDocsUrl` must be an http(s) URL — it is rendered as a link on an admin page" };
|
|
231
|
+
labels = {
|
|
232
|
+
...typeof raw.labels.credential === "string" ? { credential: raw.labels.credential } : {},
|
|
233
|
+
...typeof raw.labels.publish === "string" ? { publish: raw.labels.publish } : {},
|
|
234
|
+
...typeof raw.labels.credentialDocsUrl === "string" ? { credentialDocsUrl: raw.labels.credentialDocsUrl } : {}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
platforms: [...raw.platforms],
|
|
239
|
+
exposure: raw.exposure,
|
|
240
|
+
...raw.interactiveLogin === true ? { interactiveLogin: true } : {},
|
|
241
|
+
...raw.publishImplicit === true ? { publishImplicit: true } : {},
|
|
242
|
+
...privileged ? { privileged } : {},
|
|
243
|
+
...labels && Object.keys(labels).length > 0 ? { labels } : {}
|
|
79
244
|
};
|
|
80
245
|
}
|
|
81
246
|
//#endregion
|
|
82
247
|
//#region ../../plugin-api/dist/index.js
|
|
83
248
|
/**
|
|
84
|
-
* The
|
|
249
|
+
* The preset checks every built-in harness performs unchanged: a non-empty
|
|
85
250
|
* name, string env values, and flags that look like flags. Harnesses with
|
|
86
251
|
* extra rules run their own checks on top of these.
|
|
87
|
-
* @param
|
|
252
|
+
* @param preset - The decoded preset definition to check
|
|
88
253
|
* @returns Issues per field; `valid` is true when no issues were found
|
|
89
254
|
*/
|
|
90
|
-
function
|
|
255
|
+
function validateGenericPreset(preset) {
|
|
91
256
|
const issues = [];
|
|
92
|
-
if (!
|
|
257
|
+
if (!preset.name || preset.name.trim().length === 0) issues.push({
|
|
93
258
|
field: "name",
|
|
94
|
-
message: "
|
|
259
|
+
message: "Preset name is required."
|
|
95
260
|
});
|
|
96
|
-
for (const [key, value] of Object.entries(
|
|
261
|
+
for (const [key, value] of Object.entries(preset.env)) if (typeof value !== "string") issues.push({
|
|
97
262
|
field: "env",
|
|
98
263
|
message: `Env var "${key}" must be a string.`
|
|
99
264
|
});
|
|
100
|
-
for (const flag of
|
|
265
|
+
for (const flag of preset.flags) if (!flag.startsWith("-")) issues.push({
|
|
101
266
|
field: "flags",
|
|
102
267
|
message: `Flag "${flag}" must start with "-".`
|
|
103
268
|
});
|
|
@@ -119,7 +284,7 @@ function validateGenericProfile(profile) {
|
|
|
119
284
|
const parsed = parseManifest({
|
|
120
285
|
name: "@subshell-ai/plugin-claude-code",
|
|
121
286
|
description: "Claude Code harness plugin for Subshell",
|
|
122
|
-
version: "
|
|
287
|
+
version: "3.0.0",
|
|
123
288
|
license: "Apache-2.0",
|
|
124
289
|
type: "module",
|
|
125
290
|
main: "dist/index.js",
|
|
@@ -153,15 +318,15 @@ const parsed = parseManifest({
|
|
|
153
318
|
"tsdown": "0.22.14",
|
|
154
319
|
"typescript": "7.0.2"
|
|
155
320
|
},
|
|
156
|
-
files: ["dist"],
|
|
321
|
+
files: ["dist", "icon.svg"],
|
|
157
322
|
publishConfig: { "access": "public" },
|
|
158
323
|
subshell: {
|
|
159
|
-
"apiVersion":
|
|
324
|
+
"apiVersion": 2,
|
|
160
325
|
"id": "claude-code",
|
|
161
326
|
"type": "agent-harness",
|
|
162
327
|
"name": "Claude Code",
|
|
163
328
|
"description": "Anthropic's agentic coding assistant (interactive CLI)",
|
|
164
|
-
"icon": "
|
|
329
|
+
"icon": "icon.svg",
|
|
165
330
|
"entry": "dist/index.js",
|
|
166
331
|
"detect": {
|
|
167
332
|
"binaryName": "claude",
|
|
@@ -379,13 +544,29 @@ const reporterHook = (host, reporter, ...verb) => [
|
|
|
379
544
|
* - `Stop` / `Notification` — fire-and-forget attention reporting. The server
|
|
380
545
|
* gates delivery on the subshell's bell and derives the "waiting for you"
|
|
381
546
|
* state; a missed event costs one notification, never a broken turn.
|
|
547
|
+
* `Notification` carries a matcher because the unfiltered hook rang
|
|
548
|
+
* "Needs your approval" for EVERY notification type — `idle_prompt`,
|
|
549
|
+
* `auth_success`, `elicitation_complete` after the human had already
|
|
550
|
+
* answered (spec 2026-09-23). `Stop` reports `turn_complete` blind here:
|
|
551
|
+
* the reporter reads the hook payload and stays silent for a session
|
|
552
|
+
* parked on background work (`packages/mcp-core/src/report.ts`).
|
|
553
|
+
* - `UserPromptSubmit` / `PreToolUse` — the waiting CLEAR (`resumed`). The
|
|
554
|
+
* plane's idle-watcher can only clear what it can stat, so an agent-node
|
|
555
|
+
* pane — log on the node's disk — stayed "waiting for you" for its whole
|
|
556
|
+
* next turn (2026-09-24). The pane knows better: work has resumed when the
|
|
557
|
+
* human's prompt is submitted, AND when a tool starts after an approval
|
|
558
|
+
* answered in the dialog (approving is not a prompt, so
|
|
559
|
+
* UserPromptSubmit alone misses that path). PreToolUse fires on every tool
|
|
560
|
+
* call; the endpoint's clear is conditional, so the common stamp-less case
|
|
561
|
+
* is a silent no-op. Neither hook's stdin is read — their payloads are the
|
|
562
|
+
* prompt text and the tool input, and the report is only ever the fact.
|
|
382
563
|
* - `SessionStart` — conversation identity. The restart-resume pin
|
|
383
564
|
* (`--session-id` at launch) only survives while the pane keeps that ONE
|
|
384
565
|
* conversation, but /clear, /resume <other> and /fork start a DIFFERENT
|
|
385
566
|
* transcript id in-pane and nothing else tells the server, so the next
|
|
386
567
|
* restart would resurrect a stale conversation (observed 2026-09-03). This
|
|
387
568
|
* fires on every such transition (source: startup|resume|clear|compact|fork)
|
|
388
|
-
* and
|
|
569
|
+
* and reads stdin like `Stop` now does — the reporter forwards `session_id`
|
|
389
570
|
* from that payload and nothing else.
|
|
390
571
|
*/
|
|
391
572
|
const attentionHooks = (host, reporter) => ({
|
|
@@ -393,9 +574,20 @@ const attentionHooks = (host, reporter) => ({
|
|
|
393
574
|
type: "command",
|
|
394
575
|
command: reporterHook(host, reporter, "attention", "turn_complete")
|
|
395
576
|
}] }],
|
|
396
|
-
Notification: [{
|
|
577
|
+
Notification: [{
|
|
578
|
+
matcher: "permission_prompt|agent_needs_input|elicitation_dialog|elicitation_url_dialog",
|
|
579
|
+
hooks: [{
|
|
580
|
+
type: "command",
|
|
581
|
+
command: reporterHook(host, reporter, "attention", "needs_attention")
|
|
582
|
+
}]
|
|
583
|
+
}],
|
|
584
|
+
UserPromptSubmit: [{ hooks: [{
|
|
585
|
+
type: "command",
|
|
586
|
+
command: reporterHook(host, reporter, "attention", "resumed")
|
|
587
|
+
}] }],
|
|
588
|
+
PreToolUse: [{ hooks: [{
|
|
397
589
|
type: "command",
|
|
398
|
-
command: reporterHook(host, reporter, "attention", "
|
|
590
|
+
command: reporterHook(host, reporter, "attention", "resumed")
|
|
399
591
|
}] }],
|
|
400
592
|
SessionStart: [{ hooks: [{
|
|
401
593
|
type: "command",
|
|
@@ -410,9 +602,9 @@ function projectSlug(cwd) {
|
|
|
410
602
|
* Built-in harness: Claude Code.
|
|
411
603
|
*
|
|
412
604
|
* Launch shape:
|
|
413
|
-
* claude --settings <json> --name <subshell> [
|
|
605
|
+
* claude --settings <json> --name <subshell> [preset flags] [extra flags]
|
|
414
606
|
* Settings are passed as a JSON string via `--settings` (per-invocation, so
|
|
415
|
-
*
|
|
607
|
+
* preset config never needs to write into the user's real ~/.claude).
|
|
416
608
|
*
|
|
417
609
|
* Identity, detection and install guidance live in this package's
|
|
418
610
|
* package.json `subshell` block, NOT here: the host reads them without
|
|
@@ -456,16 +648,16 @@ const createPlugin = (host) => ({
|
|
|
456
648
|
}
|
|
457
649
|
},
|
|
458
650
|
buildCommand(input) {
|
|
459
|
-
const { binary,
|
|
651
|
+
const { binary, preset, subshellName, extraFlags, mcp, harnessSession, reporter } = input;
|
|
460
652
|
const args = [binary];
|
|
461
653
|
if (mcp?.args) args.push(...mcp.args);
|
|
462
654
|
if (harnessSession) args.push(harnessSession.mode === "resume" ? "--resume" : "--session-id", harnessSession.id);
|
|
463
|
-
const settings = { ...
|
|
655
|
+
const settings = { ...preset.settings ?? {} };
|
|
464
656
|
if (reporter) settings.hooks = attentionHooks(host, reporter);
|
|
465
657
|
else host.log.warn("no reporter resolved for this launch: attention and conversation-identity hooks are omitted");
|
|
466
658
|
if (Object.keys(settings).length > 0) args.push("--settings", JSON.stringify(settings));
|
|
467
659
|
if (subshellName) args.push("--name", subshellName);
|
|
468
|
-
for (const flag of
|
|
660
|
+
for (const flag of preset.flags) args.push(flag);
|
|
469
661
|
if (extraFlags) args.push(...extraFlags);
|
|
470
662
|
return args;
|
|
471
663
|
},
|
|
@@ -486,10 +678,10 @@ const createPlugin = (host) => ({
|
|
|
486
678
|
summary: "Subshell registers itself with every Claude Code subshell automatically (via --mcp-config)."
|
|
487
679
|
};
|
|
488
680
|
},
|
|
489
|
-
|
|
490
|
-
return
|
|
681
|
+
validatePreset(preset) {
|
|
682
|
+
return validateGenericPreset(preset);
|
|
491
683
|
},
|
|
492
|
-
|
|
684
|
+
presetSettings() {
|
|
493
685
|
return CLAUDE_SETTINGS_FIELDS;
|
|
494
686
|
},
|
|
495
687
|
suggestedEnv() {
|
package/icon.svg
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title>
|
|
2
|
+
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
|
3
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@subshell-ai/plugin-claude-code",
|
|
3
3
|
"description": "Claude Code harness plugin for Subshell",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "3.0.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -39,19 +39,17 @@
|
|
|
39
39
|
"tsdown": "0.22.14",
|
|
40
40
|
"typescript": "7.0.2"
|
|
41
41
|
},
|
|
42
|
-
"files": [
|
|
43
|
-
"dist"
|
|
44
|
-
],
|
|
42
|
+
"files": ["dist", "icon.svg"],
|
|
45
43
|
"publishConfig": {
|
|
46
44
|
"access": "public"
|
|
47
45
|
},
|
|
48
46
|
"subshell": {
|
|
49
|
-
"apiVersion":
|
|
47
|
+
"apiVersion": 2,
|
|
50
48
|
"id": "claude-code",
|
|
51
49
|
"type": "agent-harness",
|
|
52
50
|
"name": "Claude Code",
|
|
53
51
|
"description": "Anthropic's agentic coding assistant (interactive CLI)",
|
|
54
|
-
"icon": "
|
|
52
|
+
"icon": "icon.svg",
|
|
55
53
|
"entry": "dist/index.js",
|
|
56
54
|
"detect": {
|
|
57
55
|
"binaryName": "claude",
|