Skip to content
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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,14 @@ npx --yes agent-session-pack open --provider grok <session-id> --json
npx --yes agent-session-pack maintain --apply --yes --json
```

Provider wrappers (codex, claude, grok, …) run `preflight` so session ids in argv are
auto-restored before the real binary starts. Live files that differ from the archive are
**never** overwritten (`conflict`). Disable with `lifecycle disable` (removes wrappers).
Provider wrappers (codex, claude, grok, …) run `preflight` (session ids on the command line)
and **`watch`** while the app runs. Packed sessions leave tiny **stubs** so they stay listable;
when the app opens a stub (GUI session pick), watch materializes the full archive automatically.

**Manual path (always available):** `unpack` / `restore` without lifecycle.
Live files that differ from the archive are **never** overwritten (`conflict`). Disable with
`lifecycle disable` (removes wrappers).

**Manual path (always available):** `unpack` / `restore` / `open` without relying on wrappers.
</details>

<details>
Expand Down
6 changes: 4 additions & 2 deletions src/cli/commands/packAllProvidersCommand.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { copyFile, mkdir, mkdtemp, readFile, stat, utimes, writeFile } from 'node:fs/promises';
import { copyFile, mkdir, mkdtemp, readFile, utimes, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Effect } from 'effect';
Expand Down Expand Up @@ -80,7 +80,9 @@ describe('all-provider pack and unpack commands', () => {
}),
);

await expect(stat(session.path)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readFile(session.path, 'utf8')).resolves.toContain(
'agent-session-pack-archived-stub',
);
await expect(
readFile(join(vaultPath, 'manifests', 'codex', 'session-old.json'), 'utf8'),
).resolves.toContain(session.path);
Expand Down
221 changes: 221 additions & 0 deletions src/cli/commands/watchCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { spawn } from 'node:child_process';
import { defineCommand } from 'citty';
import { Effect, Schema } from 'effect';
import { createZstdCompression } from '../../core/archiveReader.js';
import type {
ArchiveFileSystemError,
ArchiveVerificationError,
CompressionAdapter,
} from '../../core/archiveWriter.js';
import type { ManifestStoreError } from '../../core/manifestStore.js';
import { resolveDefaultVaultPath } from '../../core/sessionArchive.js';
import { type ProviderId, ProviderIdSchema } from '../../core/sessionStore.js';
import {
type SessionWatchError,
type SessionWatchEvent,
watchSessionStubs,
} from '../../core/sessionWatch.js';
import {
isRestoreOnLaunchEnabled,
readSetupConfig,
type SetupConfigFileError,
} from '../../core/setupConfig.js';
import { HOME_NOT_SET_STDERR_MESSAGE } from '../homeEnv.js';

/**
* Citty command that watches archived stubs and restores sessions when opened.
*/
export const watchCommand = defineCommand({
meta: {
name: 'watch',
description:
'Watch archived session stubs and auto-restore when a provider opens them (GUI-friendly).',
},
args: {
provider: {
type: 'string',
description:
'Provider id: codex, claude, kiro, grok, kimi, opencode, gemini, cursor, or devin.',
},
json: {
type: 'boolean',
description: 'Write JSON lines for restore events.',
},
'poll-ms': {
type: 'string',
description: 'Poll interval in milliseconds (default 750).',
},
},
run: async ({ args, rawArgs }) => {
const execArgs = extractExecArgs(rawArgs);
await Effect.runPromise(
runWatchCommand({
provider: args.provider,
json: args.json,
pollMs: args['poll-ms'],
execArgv: execArgs,
}),
);
},
});

/**
* Decoded arguments for watch.
*/
export type WatchArgs = {
readonly provider: string | undefined;
readonly json: boolean | undefined;
readonly pollMs: string | undefined;
readonly execArgv: ReadonlyArray<string>;
readonly compression?: CompressionAdapter | undefined;
readonly home?: string | undefined;
readonly vaultPath?: string | undefined;
};

/**
* Runs session stub watch, optionally following a child provider process.
*
* @param args - Provider filter, poll interval, and optional exec argv.
* @returns Effect that completes when watch stops (child exit or signal).
* @example
* ```ts
* import { Effect } from 'effect';
* import { runWatchCommand } from './watchCommand.js';
*
* await Effect.runPromise(
* runWatchCommand({
* provider: 'grok',
* json: true,
* pollMs: '500',
* execArgv: [],
* }),
* );
* ```
*/
export const runWatchCommand = (
args: WatchArgs,
): Effect.Effect<
void,
| ArchiveFileSystemError
| ArchiveVerificationError
| ManifestStoreError
| SessionWatchError
| SetupConfigFileError
> =>
Effect.gen(function* () {
const home = args.home ?? process.env.HOME;

if (home === undefined) {
process.stderr.write(HOME_NOT_SET_STDERR_MESSAGE);
process.exitCode = 1;
return;
}

const provider = parseOptionalProvider(args.provider);
if (args.provider !== undefined && provider === undefined) {
process.stderr.write(`Unknown provider: ${args.provider}\n`);
process.exitCode = 2;
return;
}

const setupConfig = yield* readSetupConfig(home);
if (!isRestoreOnLaunchEnabled(setupConfig) && args.execArgv.length === 0) {
// Allow explicit watch even if disabled when following a child; wrappers enable lifecycle first.
}

const vaultPath = args.vaultPath ?? setupConfig?.vaultPath ?? resolveDefaultVaultPath(home);
const pollIntervalMs = parsePollMs(args.pollMs);
const compression = args.compression ?? createZstdCompression();
const json = args.json === true;

let childExitCode: number | undefined;
let stop = false;

if (args.execArgv.length > 0) {
const [command, ...commandArgs] = args.execArgv;
if (command === undefined) {
process.stderr.write('Missing command after -- for watch --exec.\n');
process.exitCode = 2;
return;
}

const child = spawn(command, commandArgs, {
stdio: 'inherit',
env: process.env,
});

child.on('exit', (code) => {
childExitCode = code ?? 0;
stop = true;
Comment on lines +142 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The spawned provider is not cleaned up when watchSessionStubs fails. The provider child can remain running with inherited stdio while the watch command has already failed, leaving an orphaned provider process. Keep the child handle and terminate or await it in a failure cleanup path; also handle the child error event explicitly. [missing cleanup]

Severity Level: Major ⚠️
- ⚠️ Wrapped provider can outlive failed watcher command.
- ⚠️ Orphaned processes retain provider resources and stdio.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/cli/commands/watchCommand.ts
**Line:** 142:149
**Comment:**
	*Missing Cleanup: The spawned provider is not cleaned up when `watchSessionStubs` fails. The provider child can remain running with inherited stdio while the watch command has already failed, leaving an orphaned provider process. Keep the child handle and terminate or await it in a failure cleanup path; also handle the child `error` event explicitly.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

});
Comment on lines +147 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

6. Child exit misreported 🐞 Bug ≡ Correctness

watchCommand doesn’t handle child process spawn errors and treats signal-based termination as exit
code 0 (code ?? 0), so wrappers can incorrectly report success or crash on failed spawns. This
breaks the wrapper contract of returning the provider’s real exit status.
Agent Prompt
### Issue description
When `watch` follows a child process:
- a failed spawn emits an `error` event which is currently unhandled,
- the `exit` handler maps `code === null` (signal termination) to `0`, reporting success.

### Issue Context
Lifecycle wrappers now `exec ... watch -- <real-bin> ...`, so `watch` must accurately propagate provider termination.

### Fix Focus Areas
- src/cli/commands/watchCommand.ts[142-150]
- src/cli/commands/watchCommand.ts[183-185]

### What to change
- Add `child.on('error', err => { childExitCode = 127 (or 1); stop = true; /* log */ })`.
- Update `child.on('exit', (code, signal) => { ... })` to handle `signal`:
  - map to a non-zero exit code (commonly `128 + signalNumber` if available), or
  - at minimum treat `code === null` as non-zero and include signal name in stderr output.
- Ensure `process.exitCode` reflects the derived exit status.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} else {
const onSignal = (): void => {
stop = true;
};
process.once('SIGINT', onSignal);
process.once('SIGTERM', onSignal);
}

const onEvent = (event: SessionWatchEvent): void => {
if (json) {
process.stdout.write(`${JSON.stringify({ command: 'watch', ...event })}\n`);
return;
}

process.stderr.write(`watch: ${event.provider}/${event.sessionId} → ${event.status}\n`);
};

if (!json && args.execArgv.length === 0) {
process.stderr.write(
`watch: monitoring stubs under vault ${vaultPath}${provider === undefined ? '' : ` for ${provider}`} (Ctrl+C to stop)\n`,
);
}

yield* watchSessionStubs({
vaultPath,
provider,
compression,
pollIntervalMs,
shouldStop: () => stop,
onEvent,
});

if (childExitCode !== undefined) {
process.exitCode = childExitCode === 0 ? undefined : childExitCode;
}
});

const extractExecArgs = (rawArgs: ReadonlyArray<string>): ReadonlyArray<string> => {
const separator = rawArgs.indexOf('--');
if (separator === -1) {
return [];
}

return rawArgs.slice(separator + 1);
};

const parseOptionalProvider = (provider: string | undefined): ProviderId | undefined => {
if (provider === undefined) {
return undefined;
}

const decoded = Schema.decodeUnknownEither(ProviderIdSchema)(provider);
if (decoded._tag === 'Left') {
return undefined;
}

return decoded.right;
};

const parsePollMs = (value: string | undefined): number => {
if (value === undefined) {
return 750;
}

const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 100) {
return 750;
}
Comment on lines +210 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. poll-ms parsed without schema 📘 Rule violation ⚙ Maintainability

The watch CLI boundary uses ad-hoc parsing for poll-ms via Number(...) checks rather than
Effect Schema validation. This reduces consistency and makes it easier for invalid inputs to bypass
structured boundary validation.
Agent Prompt
## Issue description
`poll-ms` is validated with manual numeric parsing instead of Effect Schema at the CLI boundary.

## Issue Context
The compliance rule prefers Effect Schema validation at module boundaries (like CLI parsing) over ad-hoc checks when schemas can express the constraints.

## Fix Focus Areas
- src/cli/commands/watchCommand.ts[210-218]

Potential approaches:
- Define an Effect Schema for `pollMs` (e.g., `Schema.NumberFromString` + bounds/refinement) and decode it, returning a typed error/effect on invalid input.
- Optionally define a full args schema for `WatchArgs` and parse/decode once at the boundary, rather than per-field parsing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


return Math.floor(parsed);
};
2 changes: 2 additions & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { restoreCommand } from './commands/restoreCommand.js';
import { checkCommand, savingsCommand } from './commands/savingsCommand.js';
import { scanCommand } from './commands/scanCommand.js';
import { unpackCommand } from './commands/unpackCommand.js';
import { watchCommand } from './commands/watchCommand.js';
import { runInteractiveCli, shouldRunInteractiveCli } from './interactiveCli.js';
import { rewriteCommandFlagAliases } from './mainArgs.js';
import { isCliEntrypoint } from './mainEntrypoint.js';
Expand All @@ -39,6 +40,7 @@ export const mainCommand = defineCommand({
restore: restoreCommand,
open: openCommand,
preflight: preflightCommand,
watch: watchCommand,
maintain: maintainCommand,
'ensure-restored': ensureRestoredCommand,
lifecycle: lifecycleCommand,
Expand Down
1 change: 1 addition & 0 deletions src/cli/mainArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const COMMAND_FLAG_ALIASES: Readonly<Record<string, string>> = {
'--pack': 'pack',
'--preflight': 'preflight',
'--restore': 'restore',
'--watch': 'watch',
'--savings': 'savings',
'--scan': 'scan',
'--unpack': 'unpack',
Expand Down
5 changes: 5 additions & 0 deletions src/core/lifecycleWrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ if command -v node >/dev/null 2>&1; then
node "$CLI_ENTRY" preflight --provider "$PROVIDER" -- "$@" >/dev/null 2>&1 || true
fi

# Follow the provider process and materialize GUI-opened stubs (no session id in argv).
if command -v node >/dev/null 2>&1; then
exec node "$CLI_ENTRY" watch --provider "$PROVIDER" -- "$REAL_BIN" "$@"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The wrapper now starts the real provider only through watch, but unlike the preflight call there is no best-effort fallback. Any watch initialization error, such as an unreadable or malformed setup configuration or vault, causes the wrapper to exit before launching the provider. Preserve the direct provider fallback when watcher startup fails. [error handling]

Severity Level: Major ⚠️
- ❌ Wrapped provider launch fails with invalid setup configuration.
- ⚠️ Codex, Claude, Grok, and other wrappers become unavailable.
- ⚠️ Provider launch depends on watcher configuration availability.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/core/lifecycleWrappers.ts
**Line:** 165:165
**Comment:**
	*Error Handling: The wrapper now starts the real provider only through `watch`, but unlike the preflight call there is no best-effort fallback. Any watch initialization error, such as an unreadable or malformed setup configuration or vault, causes the wrapper to exit before launching the provider. Preserve the direct provider fallback when watcher startup fails.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

fi
Comment on lines +163 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Wrapper invokes watch command 📘 Rule violation ⛨ Security

The provider wrapper now executes agent-session-pack watch, which performs filesystem operations
on real local session paths outside the approved CLI entry points. This violates the restriction
that only pnpm savings, pnpm evidence:local, or agent-session-pack check may reach real
session-directory access code.
Agent Prompt
## Issue description
`watch` is a new CLI entry point that watches/restores sessions by accessing real local session directories, but the compliance policy restricts such access to specific approved entry points only.

## Issue Context
`src/core/lifecycleWrappers.ts` now automatically runs `watch` for provider wrappers, making real session-directory access reachable from many non-approved entrypoints.

## Fix Focus Areas
- src/core/lifecycleWrappers.ts[163-166]
- src/cli/commands/watchCommand.ts[121-129]

Potential approaches:
- Add an explicit runtime guard (similar to an `assertApprovedEntryPoint`) that only permits real session-dir access when invoked via the approved entry points (or a dedicated, explicitly-approved env var).
- If `watch` is intended to be an approved entry point, update the project’s entrypoint/guard conventions so `watch` is treated equivalently (and ensure wrappers invoke only the guarded path).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


exec "$REAL_BIN" "$@"
`;

Expand Down
15 changes: 12 additions & 3 deletions src/core/sessionArchive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,11 @@ describe('sessionArchive dry-run and status paths', () => {
touchedOriginals: true,
}),
]);
await expect(stat(session.path)).rejects.toMatchObject({ code: 'ENOENT' });
// Packed path keeps a listable stub so GUIs can still see the session.
const stubAfterPack = JSON.parse(await readFile(session.path, 'utf8')) as {
agentSessionPack: string;
};
expect(stubAfterPack.agentSessionPack).toBe('agent-session-pack-archived-stub');

const unpackReport = await Effect.runPromise(
unpackProviderSessions({
Expand Down Expand Up @@ -434,7 +438,10 @@ describe('sessionArchive dry-run and status paths', () => {
compression: copyCompression,
}),
);
await expect(stat(session.path)).rejects.toMatchObject({ code: 'ENOENT' });
const stubAfterPack = JSON.parse(await readFile(session.path, 'utf8')) as {
agentSessionPack: string;
};
expect(stubAfterPack.agentSessionPack).toBe('agent-session-pack-archived-stub');

const disabled = await Effect.runPromise(
ensureSessionRestored({
Expand All @@ -448,7 +455,9 @@ describe('sessionArchive dry-run and status paths', () => {
}),
);
expect(disabled.status).toBe('lifecycle-disabled');
await expect(stat(session.path)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readFile(session.path, 'utf8')).resolves.toContain(
'agent-session-pack-archived-stub',
);

const restored = await Effect.runPromise(
ensureSessionRestored({
Expand Down
Loading
Loading