@oh-my-pi/pi-coding-agent 17.3.5 → 17.3.7
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/CHANGELOG.md +20 -0
- package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-1hmwwt45.md} +20 -0
- package/dist/cli.js +2839 -2839
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/cli/stats-cli.d.ts +1 -6
- package/dist/types/commands/stats.d.ts +4 -0
- package/dist/types/extensibility/extensions/runner.d.ts +21 -3
- package/dist/types/extensibility/extensions/types.d.ts +58 -0
- package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
- package/dist/types/tools/file-write-fallback.d.ts +124 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +23 -0
- package/package.json +13 -13
- package/src/cli/stats-cli.ts +6 -72
- package/src/commands/stats.ts +7 -4
- package/src/edit/hashline/filesystem.ts +9 -3
- package/src/edit/modes/patch.ts +31 -5
- package/src/extensibility/extensions/loader.ts +11 -0
- package/src/extensibility/extensions/runner.ts +115 -3
- package/src/extensibility/extensions/types.ts +60 -0
- package/src/extensibility/extensions/wrapper.ts +10 -1
- package/src/lsp/writethrough.ts +20 -10
- package/src/slash-commands/builtin-session.ts +1 -1
- package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
- package/src/tools/file-write-fallback.ts +467 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/path-utils.ts +79 -0
|
@@ -19,6 +19,7 @@ import type { MemoryRuntimeContext } from "../../memory-backend";
|
|
|
19
19
|
import { type Theme, theme } from "../../modes/theme/theme";
|
|
20
20
|
import type { AsyncJobSnapshot } from "../../session/agent-session";
|
|
21
21
|
import type { SessionManager } from "../../session/session-manager";
|
|
22
|
+
import { addFileDeleteFallback, addFileWriteFallback } from "../../tools/file-write-fallback";
|
|
22
23
|
import type { BranchHandler, NavigateTreeHandler, NewSessionHandler } from "../session-handler-types";
|
|
23
24
|
import { ManagedTimers } from "./managed-timers";
|
|
24
25
|
import { createExtensionModelQuery } from "./model-api";
|
|
@@ -375,10 +376,12 @@ export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled:
|
|
|
375
376
|
export type ShutdownHandler = () => void;
|
|
376
377
|
|
|
377
378
|
/**
|
|
378
|
-
* Emit `session_shutdown
|
|
379
|
+
* Emit `session_shutdown`, dispose file-write-fallback registrations, and clear
|
|
380
|
+
* timers owned by an extension runner.
|
|
379
381
|
*
|
|
380
|
-
* Returns whether any shutdown handlers were present.
|
|
381
|
-
* when a handler fails so extension background work
|
|
382
|
+
* Returns whether any shutdown handlers were present. Fallback disposal and timer
|
|
383
|
+
* cleanup run even when a handler fails so extension background work — and a
|
|
384
|
+
* fallback bound to this session's context — cannot outlive its host.
|
|
382
385
|
*/
|
|
383
386
|
export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise<boolean> {
|
|
384
387
|
if (!extensionRunner) return false;
|
|
@@ -389,6 +392,7 @@ export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner
|
|
|
389
392
|
});
|
|
390
393
|
return true;
|
|
391
394
|
} finally {
|
|
395
|
+
extensionRunner.disposeFileFallbacks();
|
|
392
396
|
extensionRunner.clearManagedTimers();
|
|
393
397
|
}
|
|
394
398
|
}
|
|
@@ -486,6 +490,23 @@ export class ExtensionRunner {
|
|
|
486
490
|
#managedTimers = new ManagedTimers((event, error, stack) =>
|
|
487
491
|
this.emitError({ extensionPath: "<timer>", event, error, stack }),
|
|
488
492
|
);
|
|
493
|
+
/**
|
|
494
|
+
* Disposers for the trampolines installed via {@link addFileWriteFallback} and
|
|
495
|
+
* {@link addFileDeleteFallback} — one per extension per seam it registered for.
|
|
496
|
+
* Installed during {@link initialize} (after the UI/runtime context is live, so
|
|
497
|
+
* the bound handler sees a working `ctx.ui`) and drained by
|
|
498
|
+
* {@link disposeFileFallbacks} on session shutdown so a handler from a
|
|
499
|
+
* torn-down session can never fire for a later one sharing the same process.
|
|
500
|
+
*
|
|
501
|
+
* Each trampoline re-reads its extension's handler list at call time rather than
|
|
502
|
+
* closing over a snapshot, matching how `ext.handlers` is re-read on every emit,
|
|
503
|
+
* so an extension that already had a handler for that seam at `initialize` picks
|
|
504
|
+
* up later additions to it. A seam the extension registered NOTHING for gets no
|
|
505
|
+
* trampoline at all, which keeps the registry empty for a host with no fallbacks;
|
|
506
|
+
* the cost is that a first registration for that seam after `initialize` never
|
|
507
|
+
* takes effect, which is why the API documents load-time registration.
|
|
508
|
+
*/
|
|
509
|
+
#fileFallbackDisposers: Array<() => void> = [];
|
|
489
510
|
/**
|
|
490
511
|
* Dedup markers for `tool_call` emission, keyed `${toolCallId}:${toolName}`.
|
|
491
512
|
* The agent loop emits `tool_call` at arg-prep time (before scheduling and
|
|
@@ -609,6 +630,18 @@ export class ExtensionRunner {
|
|
|
609
630
|
return this.sessionManager.getCwd();
|
|
610
631
|
}
|
|
611
632
|
|
|
633
|
+
/**
|
|
634
|
+
* Stable id of the session this runner serves. Read through `sessionManager`
|
|
635
|
+
* for the same reason as {@link cwd}: it is this session's own, never a
|
|
636
|
+
* process-global, so a subagent runner reports itself and not its parent.
|
|
637
|
+
*
|
|
638
|
+
* Used to attribute a denied file write or delete to the session that issued
|
|
639
|
+
* it, since the fallback registry those handlers live in is process-wide.
|
|
640
|
+
*/
|
|
641
|
+
get sessionId(): string {
|
|
642
|
+
return this.sessionManager.getSessionId();
|
|
643
|
+
}
|
|
644
|
+
|
|
612
645
|
initialize(
|
|
613
646
|
actions: ExtensionActions,
|
|
614
647
|
contextActions: ExtensionContextActions,
|
|
@@ -666,6 +699,75 @@ export class ExtensionRunner {
|
|
|
666
699
|
this.#mode = mode;
|
|
667
700
|
this.#initialized = true;
|
|
668
701
|
|
|
702
|
+
// Re-initialize (e.g. a mode switch rewiring UI/runtime actions) must not
|
|
703
|
+
// accumulate duplicate global registrations — drop the prior generation before
|
|
704
|
+
// installing this one's trampolines.
|
|
705
|
+
this.disposeFileFallbacks();
|
|
706
|
+
for (const ext of this.extensions) {
|
|
707
|
+
// Nothing registered by this extension means no trampoline, so a host with
|
|
708
|
+
// no fallback-registering extension leaves the seam genuinely empty and
|
|
709
|
+
// `hasFileWriteFallback()`/`hasFileDeleteFallback()` false — the invariant
|
|
710
|
+
// the whole feature rests on. Each seam is checked separately, so an
|
|
711
|
+
// extension that only brokers writes never appears in the delete registry.
|
|
712
|
+
if (ext.fileWriteFallbackHandlers.length === 0 && ext.fileDeleteFallbackHandlers.length === 0) continue;
|
|
713
|
+
// One trampoline per extension per seam, not per handler: the list is walked
|
|
714
|
+
// at mutation time so a handler this extension adds later still takes effect,
|
|
715
|
+
// and `createContext()` takes no extension argument, so within one invocation
|
|
716
|
+
// a single context is all any of this extension's handlers would have
|
|
717
|
+
// received anyway.
|
|
718
|
+
//
|
|
719
|
+
// The context is built PER INVOCATION rather than captured here, matching
|
|
720
|
+
// every other dispatch site. `createContext()` materializes `cwd` and
|
|
721
|
+
// `hasUI` as values, so a trampoline holding one context for the life of the
|
|
722
|
+
// session would keep handing handlers the workspace this runner initialized
|
|
723
|
+
// in — wrong the moment `SessionManager.moveTo()` relocates the session
|
|
724
|
+
// (`/move`), and a handler that scopes or prompts against `ctx.cwd` would
|
|
725
|
+
// then allow the old workspace and deny the new one. A denied mutation is a
|
|
726
|
+
// rare path, so the extra object costs nothing that matters.
|
|
727
|
+
//
|
|
728
|
+
// Isolation is per HANDLER, not per extension. The registry only sees one
|
|
729
|
+
// trampoline per extension, so a throw escaping this loop would advance the
|
|
730
|
+
// registry to the NEXT extension and skip every later handler this one
|
|
731
|
+
// registered — breaking both the documented "a throwing handler is skipped"
|
|
732
|
+
// contract and registration order for a backup-handler setup.
|
|
733
|
+
if (ext.fileWriteFallbackHandlers.length > 0) {
|
|
734
|
+
this.#fileFallbackDisposers.push(
|
|
735
|
+
addFileWriteFallback(async req => {
|
|
736
|
+
const ctx = this.createContext();
|
|
737
|
+
for (const handler of ext.fileWriteFallbackHandlers) {
|
|
738
|
+
try {
|
|
739
|
+
if (await handler(req, ctx)) return true;
|
|
740
|
+
} catch (error) {
|
|
741
|
+
logger.warn("Extension file write fallback handler threw; trying next handler", {
|
|
742
|
+
extension: ext.path,
|
|
743
|
+
error: error instanceof Error ? error.message : String(error),
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return false;
|
|
748
|
+
}),
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (ext.fileDeleteFallbackHandlers.length > 0) {
|
|
752
|
+
this.#fileFallbackDisposers.push(
|
|
753
|
+
addFileDeleteFallback(async req => {
|
|
754
|
+
const ctx = this.createContext();
|
|
755
|
+
for (const handler of ext.fileDeleteFallbackHandlers) {
|
|
756
|
+
try {
|
|
757
|
+
if (await handler(req, ctx)) return true;
|
|
758
|
+
} catch (error) {
|
|
759
|
+
logger.warn("Extension file delete fallback handler threw; trying next handler", {
|
|
760
|
+
extension: ext.path,
|
|
761
|
+
error: error instanceof Error ? error.message : String(error),
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return false;
|
|
766
|
+
}),
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
669
771
|
// Drain events buffered by emitCredentialDisabled() before initialize ran. The
|
|
670
772
|
// spread adds the `type` discriminator — `event` is the pi-ai shape (no `type`).
|
|
671
773
|
// Deferred by one microtask so callers that register an onError listener
|
|
@@ -1098,6 +1200,16 @@ export class ExtensionRunner {
|
|
|
1098
1200
|
this.#managedTimers.clearAll();
|
|
1099
1201
|
}
|
|
1100
1202
|
|
|
1203
|
+
/**
|
|
1204
|
+
* Remove every file write and delete fallback this runner installed into the
|
|
1205
|
+
* process-wide registries. Called on session shutdown (and before reinstalling
|
|
1206
|
+
* on a re-{@link initialize}) so a handler bound to a torn-down session's
|
|
1207
|
+
* context can never fire for another session sharing this process.
|
|
1208
|
+
*/
|
|
1209
|
+
disposeFileFallbacks(): void {
|
|
1210
|
+
for (const dispose of this.#fileFallbackDisposers.splice(0)) dispose();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1101
1213
|
createCommandContext(): ExtensionCommandContext {
|
|
1102
1214
|
return {
|
|
1103
1215
|
...this.createContext(),
|
|
@@ -76,6 +76,7 @@ import type {
|
|
|
76
76
|
WriteToolInput,
|
|
77
77
|
} from "../../tools";
|
|
78
78
|
import type { ApprovalMode } from "../../tools/approval";
|
|
79
|
+
import type { FileDeleteFallbackHandler, FileWriteFallbackHandler } from "../../tools/file-write-fallback";
|
|
79
80
|
import type { EventBus } from "../../utils/event-bus";
|
|
80
81
|
import type {
|
|
81
82
|
AgentEndEvent,
|
|
@@ -1256,6 +1257,63 @@ export interface ExtensionAPI {
|
|
|
1256
1257
|
/** Register a tool that the LLM can call. */
|
|
1257
1258
|
registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void;
|
|
1258
1259
|
|
|
1260
|
+
/**
|
|
1261
|
+
* Register a fallback writer consulted when a native `write`/`edit` byte-write is
|
|
1262
|
+
* denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Every other write
|
|
1263
|
+
* error is unaffected. Handlers run in registration order; the first one to
|
|
1264
|
+
* resolve `true` counts as the bytes being durably on disk, and the native tool
|
|
1265
|
+
* continues as if its own write had succeeded — including recording its file
|
|
1266
|
+
* snapshot under the real destination path, so a later hashline `edit` on that
|
|
1267
|
+
* path keeps working. Intended for a host embedding the agent inside a sandbox
|
|
1268
|
+
* that denies direct filesystem writes but exposes a privileged write channel.
|
|
1269
|
+
*
|
|
1270
|
+
* A denial that `Bun.write` masks as `ENOENT` — a write into a directory the host
|
|
1271
|
+
* may not create — also diverts here, with `req.dst`'s parent absent and the
|
|
1272
|
+
* handler responsible for creating it.
|
|
1273
|
+
*
|
|
1274
|
+
* `req.dst` is symlink-RESOLVED: the path the failed write itself acted on, not
|
|
1275
|
+
* the one the tool was given. A link anywhere in a lexical path redirects the
|
|
1276
|
+
* bytes while still passing a prefix allowlist, so treat `req.dst` as
|
|
1277
|
+
* authoritative. A destination that cannot be resolved is never brokered.
|
|
1278
|
+
*
|
|
1279
|
+
* Call this during extension load, like the other `register*` methods: handlers
|
|
1280
|
+
* are installed when the runner initializes, so an extension that has registered
|
|
1281
|
+
* none by then is skipped and a first registration made later never takes effect.
|
|
1282
|
+
*
|
|
1283
|
+
* The underlying registry is process-wide, so a handler may be consulted for a
|
|
1284
|
+
* denied write from any session in the process, not only its own.
|
|
1285
|
+
* `req.sessionId` names the session that issued the write and
|
|
1286
|
+
* `ctx.sessionManager.getSessionId()` names the handler's own; compare them
|
|
1287
|
+
* before prompting, because `ctx.ui` belongs to the latter. See
|
|
1288
|
+
* `docs/extensions.md`.
|
|
1289
|
+
*/
|
|
1290
|
+
registerFileWriteFallback(handler: FileWriteFallbackHandler): void;
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Register a fallback deleter consulted when a native `edit`/`apply_patch` unlink is
|
|
1294
|
+
* denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Covers `edit`'s `REM`,
|
|
1295
|
+
* the source side of a hashline `MV`, and `apply_patch`'s delete op. Return `true`
|
|
1296
|
+
* once `dst` is gone from disk.
|
|
1297
|
+
*
|
|
1298
|
+
* A handler MUST remove `dst` with a plain unlink and MUST NOT fall back to a
|
|
1299
|
+
* recursive removal. `unlink` on a directory reports `EPERM` on Darwin, so the seam
|
|
1300
|
+
* checks the target before diverting — but when the target's own metadata is behind
|
|
1301
|
+
* the same boundary that denied the unlink, which is the common sandbox case, that
|
|
1302
|
+
* check cannot be resolved and `dst` may be a directory. `req.confirmedFile` says
|
|
1303
|
+
* which situation the handler is in.
|
|
1304
|
+
*
|
|
1305
|
+
* `req.dst` resolves every component ABOVE the last, for the same reason the
|
|
1306
|
+
* write seam resolves all of them; the last is left alone because `unlink`
|
|
1307
|
+
* removes a link rather than its target, so `req.dst` may name a link.
|
|
1308
|
+
*
|
|
1309
|
+
* Separate from {@link registerFileWriteFallback} on purpose. A write handler
|
|
1310
|
+
* brokers `req.content` to `req.dst`, so a delete request reaching it with no
|
|
1311
|
+
* content invites brokering an empty write and truncating the file instead of
|
|
1312
|
+
* removing it. Registering for deletes is therefore an explicit opt-in, and the
|
|
1313
|
+
* same load-time and process-wide notes above apply.
|
|
1314
|
+
*/
|
|
1315
|
+
registerFileDeleteFallback(handler: FileDeleteFallbackHandler): void;
|
|
1316
|
+
|
|
1259
1317
|
// =========================================================================
|
|
1260
1318
|
// Command, Shortcut, Flag Registration
|
|
1261
1319
|
// =========================================================================
|
|
@@ -1632,6 +1690,8 @@ export interface Extension {
|
|
|
1632
1690
|
tools: Map<string, RegisteredTool<any, any>>;
|
|
1633
1691
|
toolRegistrationListeners?: Set<ToolRegistrationListener>;
|
|
1634
1692
|
assistantThinkingRenderers: AssistantThinkingRenderer[];
|
|
1693
|
+
fileWriteFallbackHandlers: FileWriteFallbackHandler[];
|
|
1694
|
+
fileDeleteFallbackHandlers: FileDeleteFallbackHandler[];
|
|
1635
1695
|
messageRenderers: Map<string, MessageRenderer>;
|
|
1636
1696
|
commands: Map<string, RegisteredCommand>;
|
|
1637
1697
|
flags: Map<string, ExtensionFlag>;
|
|
@@ -14,6 +14,7 @@ import type { Settings } from "../../config/settings";
|
|
|
14
14
|
import type { Theme } from "../../modes/theme/theme";
|
|
15
15
|
import { type ApprovalMode, formatApprovalPrompt, resolveApproval, truncateForPrompt } from "../../tools/approval";
|
|
16
16
|
import { defaultLoadModeForToolName } from "../../tools/essential-tools";
|
|
17
|
+
import { withFileMutationSession } from "../../tools/file-write-fallback";
|
|
17
18
|
import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
|
|
18
19
|
import { applyToolProxy } from "../tool-proxy";
|
|
19
20
|
import type { ExtensionRunner } from "./runner";
|
|
@@ -349,7 +350,15 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
349
350
|
let executionError: Error | undefined;
|
|
350
351
|
|
|
351
352
|
try {
|
|
352
|
-
|
|
353
|
+
// A denied file write or delete inside this tool can be brokered to an
|
|
354
|
+
// extension handler, and that registry is PROCESS-WIDE — so the session is
|
|
355
|
+
// named here, the one place where every tool's execution and the runner
|
|
356
|
+
// that owns the handlers are both in scope (`sdk.ts` wraps the whole tool
|
|
357
|
+
// registry with this class whenever a runner exists). Inert with no
|
|
358
|
+
// fallback registered: no scope is entered.
|
|
359
|
+
result = await withFileMutationSession(this.runner.sessionId, () =>
|
|
360
|
+
this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context),
|
|
361
|
+
);
|
|
353
362
|
} catch (err) {
|
|
354
363
|
executionError = err instanceof Error ? err : new Error(String(err));
|
|
355
364
|
result = {
|
package/src/lsp/writethrough.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { isEnoent, logger, once, untilAborted } from "@oh-my-pi/pi-utils";
|
|
3
3
|
import type { BunFile } from "bun";
|
|
4
|
+
import { isPermissionDeniedError, writeFileWithFallback } from "../tools/file-write-fallback";
|
|
4
5
|
import { FileChangeType, notifyWorkspaceWatchedFiles } from "./client";
|
|
5
6
|
import { getServersForFile } from "./config";
|
|
6
7
|
import {
|
|
@@ -66,11 +67,7 @@ export async function writethroughNoop(
|
|
|
66
67
|
_batch?: LspWritethroughBatchRequest,
|
|
67
68
|
_getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
|
|
68
69
|
): Promise<FileDiagnosticsResult | undefined> {
|
|
69
|
-
|
|
70
|
-
await file.write(content);
|
|
71
|
-
} else {
|
|
72
|
-
await Bun.write(dst, content);
|
|
73
|
-
}
|
|
70
|
+
await writeFileWithFallback(dst, content, file);
|
|
74
71
|
return undefined;
|
|
75
72
|
}
|
|
76
73
|
|
|
@@ -78,6 +75,12 @@ interface PendingWritethrough {
|
|
|
78
75
|
dst: string;
|
|
79
76
|
file?: BunFile;
|
|
80
77
|
changeType: FileChangeType;
|
|
78
|
+
/**
|
|
79
|
+
* The bytes this entry committed. The flush prefers a fresh read of `dst` so
|
|
80
|
+
* post-processing sees whatever else in the batch touched the file, and falls
|
|
81
|
+
* back to these when that read is denied.
|
|
82
|
+
*/
|
|
83
|
+
content: string;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
interface RunLspWritethroughOptions {
|
|
@@ -288,7 +291,7 @@ async function runLspWritethrough(
|
|
|
288
291
|
const contentAlreadyWritten = runOptions?.contentAlreadyWritten ?? false;
|
|
289
292
|
|
|
290
293
|
let finalContent = content;
|
|
291
|
-
const writeContent = async (value: string) => (
|
|
294
|
+
const writeContent = async (value: string) => writeFileWithFallback(dst, value, file);
|
|
292
295
|
const getWritePromise = once(() =>
|
|
293
296
|
contentAlreadyWritten && finalContent === content ? Promise.resolve() : writeContent(finalContent),
|
|
294
297
|
);
|
|
@@ -458,9 +461,16 @@ async function flushWritethroughBatch(
|
|
|
458
461
|
try {
|
|
459
462
|
content = await fs.promises.readFile(entry.dst, "utf8");
|
|
460
463
|
} catch (error) {
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
|
|
464
|
+
if (isEnoent(error)) {
|
|
465
|
+
bundle?.finalize(undefined);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
// A brokered write lands bytes this process may not be able to read
|
|
469
|
+
// back: a sandbox that denies the write commonly denies the read too.
|
|
470
|
+
// Failing here would fail a flush whose every write succeeded, so the
|
|
471
|
+
// content this entry committed stands in for the unreadable file.
|
|
472
|
+
if (!isPermissionDeniedError(error)) throw error;
|
|
473
|
+
content = entry.content;
|
|
464
474
|
}
|
|
465
475
|
const deferredInner =
|
|
466
476
|
bundle &&
|
|
@@ -552,7 +562,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
|
|
|
552
562
|
}
|
|
553
563
|
|
|
554
564
|
const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
|
|
555
|
-
state.entries.set(dst, { dst, file, changeType });
|
|
565
|
+
state.entries.set(dst, { dst, file, changeType, content });
|
|
556
566
|
if (!batch.flush) return undefined;
|
|
557
567
|
|
|
558
568
|
writethroughBatches.delete(batch.id);
|
|
@@ -336,7 +336,7 @@ export const BUILTIN_SESSION_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
|
|
|
336
336
|
{
|
|
337
337
|
name: "stats",
|
|
338
338
|
description: "Launch the local stats dashboard",
|
|
339
|
-
inlineHint: "[--port <port>]",
|
|
339
|
+
inlineHint: "[--port <port>] [--host <host>]",
|
|
340
340
|
allowArgs: true,
|
|
341
341
|
handle: async (command, runtime) => {
|
|
342
342
|
const parsed = parseStatsDashboardArgs(command.args);
|
|
@@ -11,6 +11,7 @@ interface StatsDashboardServer {
|
|
|
11
11
|
|
|
12
12
|
export interface StatsDashboardArgs {
|
|
13
13
|
port: number;
|
|
14
|
+
host: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface StatsDashboardLaunchResult {
|
|
@@ -20,7 +21,7 @@ export interface StatsDashboardLaunchResult {
|
|
|
20
21
|
|
|
21
22
|
let activeStatsServer: StatsDashboardServer | undefined;
|
|
22
23
|
|
|
23
|
-
const STATS_DASHBOARD_USAGE = "Usage: /stats [--port <port>]";
|
|
24
|
+
const STATS_DASHBOARD_USAGE = "Usage: /stats [--port <port>] [--host <host>]";
|
|
24
25
|
|
|
25
26
|
function parsePort(value: string | undefined): number | string {
|
|
26
27
|
if (!value) return `Missing port. ${STATS_DASHBOARD_USAGE}`;
|
|
@@ -33,6 +34,7 @@ function parsePort(value: string | undefined): number | string {
|
|
|
33
34
|
export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { error: string } {
|
|
34
35
|
const tokens = args.split(/\s+/).filter(Boolean);
|
|
35
36
|
let port = DEFAULT_STATS_DASHBOARD_PORT;
|
|
37
|
+
let host = "127.0.0.1";
|
|
36
38
|
|
|
37
39
|
for (let i = 0; i < tokens.length; i++) {
|
|
38
40
|
const token = tokens[i];
|
|
@@ -48,28 +50,40 @@ export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { er
|
|
|
48
50
|
port = parsed;
|
|
49
51
|
continue;
|
|
50
52
|
}
|
|
53
|
+
if (token === "--host") {
|
|
54
|
+
const value = tokens[++i];
|
|
55
|
+
if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` };
|
|
56
|
+
host = value;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (token.startsWith("--host=")) {
|
|
60
|
+
const value = token.slice("--host=".length);
|
|
61
|
+
if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` };
|
|
62
|
+
host = value;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
51
65
|
return { error: `Unknown option: ${token}. ${STATS_DASHBOARD_USAGE}` };
|
|
52
66
|
}
|
|
53
67
|
|
|
54
|
-
return { port };
|
|
68
|
+
return { port, host };
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
export async function launchStatsDashboard(args: StatsDashboardArgs): Promise<StatsDashboardLaunchResult> {
|
|
58
72
|
const { processed, files } = await stats.syncAllSessions();
|
|
59
73
|
const total = await stats.getTotalMessageCount();
|
|
60
|
-
let
|
|
74
|
+
let requestedAddressIgnored = false;
|
|
61
75
|
|
|
62
76
|
if (!activeStatsServer) {
|
|
63
|
-
activeStatsServer = await stats.startServer(args.port);
|
|
64
|
-
} else if (args.port !== activeStatsServer.port) {
|
|
65
|
-
|
|
77
|
+
activeStatsServer = await stats.startServer(args.port, args.host);
|
|
78
|
+
} else if (args.port !== activeStatsServer.port || args.host !== activeStatsServer.hostname) {
|
|
79
|
+
requestedAddressIgnored = true;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
const url =
|
|
82
|
+
const url = stats.formatStatsDashboardUrl(activeStatsServer.hostname, activeStatsServer.port);
|
|
69
83
|
openUtils.openPath(url);
|
|
70
84
|
|
|
71
|
-
const serverLine =
|
|
72
|
-
? `Dashboard already running at: ${url} (requested
|
|
85
|
+
const serverLine = requestedAddressIgnored
|
|
86
|
+
? `Dashboard already running at: ${url} (requested ${args.host}:${args.port} ignored)`
|
|
73
87
|
: `Dashboard available at: ${url}`;
|
|
74
88
|
|
|
75
89
|
return {
|