Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ minor releases until 1.0.

## Unreleased

### Added (`st ding --root` — pin the state root in the command, plus an unset-root WARN)

`st ding` now accepts `--root PATH` (alias `--st-root`), which overrides
`$ST_ROOT` and the install default for the state root the daemon watches.
Put it in the launch command so it **survives a `pty restart`** — a restart
reuses the stored command but can drop/replace env, so a daemon relying only
on `$ST_ROOT` can silently fall back to the default root (`~/.local/state/smalltalk`)
and watch the wrong inbox. That mismatch makes a daemon re-poke stranded
messages from the wrong root forever (phantom pokes) while never delivering
the agent's real inbox.

Also: on startup, if `$ST_ROOT` is unset (and no `--root`) **and** more than
one state root exists on disk under `~/.local/state/`, `st ding` now emits a
loud one-line stderr WARN naming the roots — the exact ambiguity that
otherwise takes a multi-round investigation to spot.

Both are additive and back-compat: existing `st ding` invocations are
unchanged (single-root setups never warn).

### Added (`st ding` — stable `[id:<rand6>]` discriminator in the `[DING]` poke line)

The `[DING]` poke now carries a short, stable per-message id:
Expand Down
74 changes: 73 additions & 1 deletion src/commands/ding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1670,6 +1670,37 @@ export function probePtyOnPath(): PtyProbeResult {
return { available: true };
}

/**
* Best-effort scan of `~/.local/state/*` for directories that look like an
* st state-root (contain at least one agent dir with an `inbox/`). Used only
* to WARN when `st ding` is about to watch the DEFAULT root with `ST_ROOT`
* unset while other roots also exist — the exact setup that silently makes a
* daemon watch the wrong inbox. Read-only; swallows all fs errors.
*/
function plausibleStateRoots(home: string): string[] {
const base = join(home, '.local/state');
let entries;
try {
entries = readdirSync(base, { withFileTypes: true });
} catch {
return [];
}
const roots: string[] = [];
for (const e of entries) {
if (!e.isDirectory()) continue;
const rootPath = join(base, e.name);
try {
const looksLikeRoot = readdirSync(rootPath, { withFileTypes: true }).some(
(k) => k.isDirectory() && existsSync(join(rootPath, k.name, 'inbox'))
);
if (looksLikeRoot) roots.push(rootPath);
} catch {
// unreadable subdir — skip
}
}
return roots;
}

export async function cmdDingCli(
args: readonly string[],
ctx: CliContext,
Expand All @@ -1682,6 +1713,11 @@ export async function cmdDingCli(
let statusRefreshIntervalMs: number | undefined;
// brief-031 amendment: default ON. CLI flag flips to false.
let exitWhenSessionGone = true;
// Explicit state-root override. When set, wins over ctx.stRoot / $ST_ROOT.
// Lives on the COMMAND LINE (not just env) so a `pty restart` — which
// reuses the stored command but can drop/replace env — can't silently
// send the daemon back to the default root. See the ST_ROOT-mismatch class.
let rootArg: string | undefined;

for (let i = 0; i < args.length; i++) {
const a = args[i]!;
Expand All @@ -1708,6 +1744,12 @@ export async function cmdDingCli(
` ${invokedName(ctx.env)} ding my-claude-session --identity cos\n` +
` ST_DING_PANE_GUARD=0 ${invokedName(ctx.env)} ding my-session # opt out of typing-aware guard\n\n` +
' --identity ID Smalltalk identity to watch. Defaults to $ST_AGENT.\n' +
' --root PATH (alias --st-root) State root to watch. Overrides $ST_ROOT and the\n' +
' install default. Put this in the LAUNCH COMMAND so\n' +
' it survives a `pty restart` (which reuses the stored\n' +
' command but can drop env) — otherwise an unset root\n' +
' silently falls back to the default and may watch the\n' +
' wrong inbox.\n' +
' --interval MS Status poll interval while buffered. Default 1000ms.\n' +
' --tidy-interval-ms MS Tidy-check tick interval. Default 20 min.\n' +
' Set to 0 to disable tidy-check entirely\n' +
Expand Down Expand Up @@ -1798,6 +1840,15 @@ export async function cmdDingCli(
case '--no-exit-when-session-gone':
exitWhenSessionGone = false;
break;
case '--root':
case '--st-root': {
const v = args[++i];
if (v === undefined || v.length === 0) {
throw new Error(`${a} requires a state-root path`);
}
rootArg = v;
break;
}
default:
if (a.startsWith('-')) throw new Error(`unknown flag: ${a}`);
if (ptySession === undefined) {
Expand All @@ -1812,10 +1863,31 @@ export async function cmdDingCli(
throw new Error('st ding requires a <pty-session> name');
}

const root = ctx.stRoot;
const root = rootArg ?? ctx.stRoot;
if (!root) {
throw new Error('ST_ROOT must be set for `st ding`');
}
// Ambiguous-root guard: if the root was neither passed (--root) nor set
// ($ST_ROOT) — i.e. we fell back to the install default — and more than
// one state root exists on disk, warn loudly. A ding watching the wrong
// root silently re-pokes stranded messages (phantom pokes) and never
// delivers the agent's real inbox — the ST_ROOT-mismatch class this flag
// exists to prevent. One warning is far cheaper than that diagnosis.
const rootWasExplicit =
rootArg !== undefined ||
(ctx.env.ST_ROOT !== undefined && ctx.env.ST_ROOT.length > 0);
if (!rootWasExplicit) {
const roots = plausibleStateRoots(ctx.env.HOME ?? homedir());
if (roots.length > 1) {
ctx.stderr(
`[st ding] WARN: ST_ROOT unset — defaulting to ${root}, but ` +
`${roots.length} state roots exist on disk: ${roots.join(', ')}. ` +
`If this agent's network uses a different root, pass --root <path> ` +
`(or set ST_ROOT) — otherwise this ding may watch the wrong inbox ` +
`and emit phantom pokes for stranded messages.\n`
);
}
}
const identityValue = identityArg ?? ctx.env.ST_AGENT;
if (!identityValue) {
throw new Error(
Expand Down
71 changes: 71 additions & 0 deletions tests/unit/ding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,77 @@ describe('cmdDingCli — arg parsing', () => {
).rejects.toThrow(/--tidy-interval-ms must be a non-negative integer/);
});

it('--root requires a path value', async () => {
await expect(
cmdDingCli(['session', '--root'], ctx({ ST_AGENT: 'bob' }))
).rejects.toThrow(/--root requires a state-root path/);
});

it('--root <path> is accepted (parses, reaches the identity check)', async () => {
// No identity → it must throw the IDENTITY error, NOT "unknown flag",
// which proves --root was parsed and consumed its value.
await expect(
cmdDingCli(['session', '--root', '/custom/root'], ctx())
).rejects.toThrow(/needs --identity ID or \$ST_AGENT/);
});

it('--st-root is an accepted alias for --root', async () => {
await expect(
cmdDingCli(['session', '--st-root', '/custom/root'], ctx())
).rejects.toThrow(/needs --identity ID or \$ST_AGENT/);
});

it('WARNs when ST_ROOT is unset AND >1 state root exists on disk', async () => {
// A temp $HOME with two plausible st-roots (each has an agent/inbox).
const home = mkdtempSync(join(tmpdir(), 'ding-root-warn-'));
mkdirSync(join(home, '.local/state/smalltalk/alice/inbox'), {
recursive: true,
});
mkdirSync(join(home, '.local/state/convoy/bob/inbox'), { recursive: true });
try {
const c = ctx({ HOME: home }); // ST_ROOT unset, no ST_AGENT
// No identity → throws after the WARN block runs, so we can observe it.
await expect(cmdDingCli(['session'], c)).rejects.toThrow(/--identity/);
expect(c.stderrBuf.value).toMatch(/WARN: ST_ROOT unset/);
expect(c.stderrBuf.value).toContain('/.local/state/smalltalk');
expect(c.stderrBuf.value).toContain('/.local/state/convoy');
} finally {
rmSync(home, { recursive: true, force: true });
}
});

it('does NOT warn when --root is passed (root is explicit)', async () => {
const home = mkdtempSync(join(tmpdir(), 'ding-root-nowarn-'));
mkdirSync(join(home, '.local/state/smalltalk/alice/inbox'), {
recursive: true,
});
mkdirSync(join(home, '.local/state/convoy/bob/inbox'), { recursive: true });
try {
const c = ctx({ HOME: home });
await expect(
cmdDingCli(['session', '--root', '/x'], c)
).rejects.toThrow(/--identity/);
expect(c.stderrBuf.value).not.toMatch(/WARN: ST_ROOT unset/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

it('does NOT warn when ST_ROOT is set (root is explicit)', async () => {
const home = mkdtempSync(join(tmpdir(), 'ding-root-envset-'));
mkdirSync(join(home, '.local/state/smalltalk/alice/inbox'), {
recursive: true,
});
mkdirSync(join(home, '.local/state/convoy/bob/inbox'), { recursive: true });
try {
const c = ctx({ HOME: home, ST_ROOT: '/explicit/root' });
await expect(cmdDingCli(['session'], c)).rejects.toThrow(/--identity/);
expect(c.stderrBuf.value).not.toMatch(/WARN: ST_ROOT unset/);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

it('malformed ST_DING_RESCAN_INTERVAL_MS env → warning + fall back to default', async () => {
// Regression guard: a typo in an env var can't crash the daemon.
// The warning is emitted, the default kicks in. cmdDingCli would
Expand Down