Feature /resume command to continue session#390
Feature /resume command to continue session#390yashksaini-coder wants to merge 16 commits intoNano-Collective:mainfrom
/resume command to continue session#390Conversation
- Introduced session configuration options including autosave, save interval, max sessions, retention days, and directory. - Implemented session loading from project-level, global, and home directory configurations. - Added a new command to resume previous chat sessions in the help documentation. - Integrated session autosave hook in the main application component.
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 5.81.0 to 5.85.0. - [Release notes](https://github.com/webpro-nl/knip/releases) - [Commits](https://github.com/webpro-nl/knip/commits/knip@5.85.0/packages/knip) --- updated-dependencies: - dependency-name: knip dependency-version: 5.85.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com>
Bumps [cheerio](https://github.com/cheeriojs/cheerio) from 1.1.2 to 1.2.0. - [Release notes](https://github.com/cheeriojs/cheerio/releases) - [Commits](cheeriojs/cheerio@v1.1.2...v1.2.0) --- updated-dependencies: - dependency-name: cheerio dependency-version: 1.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com>
Bumps [@ai-sdk/openai-compatible](https://github.com/vercel/ai) from 2.0.27 to 2.0.30. - [Release notes](https://github.com/vercel/ai/releases) - [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md) - [Commits](https://github.com/vercel/ai/compare/@ai-sdk/openai-compatible@2.0.27...@ai-sdk/openai-compatible@2.0.30) --- updated-dependencies: - dependency-name: "@ai-sdk/openai-compatible" dependency-version: 2.0.30 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com>
Bumps [@ai-sdk/google](https://github.com/vercel/ai) from 3.0.30 to 3.0.33. - [Release notes](https://github.com/vercel/ai/releases) - [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md) - [Commits](https://github.com/vercel/ai/compare/@ai-sdk/google@3.0.30...@ai-sdk/google@3.0.33) --- updated-dependencies: - dependency-name: "@ai-sdk/google" dependency-version: 3.0.33 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com>
Bumps [ai](https://github.com/vercel/ai) from 6.0.97 to 6.0.104. - [Release notes](https://github.com/vercel/ai/releases) - [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md) - [Commits](https://github.com/vercel/ai/compare/ai@6.0.97...ai@6.0.104) --- updated-dependencies: - dependency-name: ai dependency-version: 6.0.104 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com>
Addresses feedback from PR Nano-Collective#383 review: - Extract getSafeMemory() and getSafeCpuUsage() into shared module (source/utils/logging/safe-process.ts) - Import safe functions in performance.ts, request-tracker.ts, memory-check.ts, and health-monitor.ts - Fix double call to getSafeMemory() in takePerformanceSnapshot() by capturing value once and reusing - Wrap direct process.memoryUsage() calls in chat-handler.ts and mcp-client.ts with getSafeMemory() - All files properly import from node:process via shared module Files modified: - source/utils/logging/safe-process.ts (new) - source/utils/logging/performance.ts - source/utils/logging/request-tracker.ts - source/utils/logging/health-monitor/checks/memory-check.ts - source/utils/logging/health-monitor/core/health-monitor.ts - source/ai-sdk-client/chat/chat-handler.ts - source/mcp/mcp-client.ts
There was a problem hiding this comment.
Pull request overview
Adds first-class session persistence and a /resume command so users can autosave and restore chat sessions across runs, plus dependency override updates to address security audit findings.
Changes:
- Introduces on-disk session storage (
sessions.jsonindex + per-session JSON) with autosave and retention/limits. - Adds
/resume(and aliases) with an interactive selector UI and direct resume modes (last,<id>,<n>), wiring it into app state/handlers. - Updates configuration loading/types to support
nanocoder.sessions.*, and applies pnpm overrides + adds anauditscript.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| source/types/config.ts | Adds sessions config shape to AppConfig. |
| source/types/app.ts | Extends MessageSubmissionOptions with session selector/resume callbacks. |
| source/config/index.ts | Loads/validates session configuration from config files with defaults. |
| source/session/session-manager.ts | Implements session CRUD, index maintenance, retention, and limits. |
| source/hooks/useSessionAutosave.ts | Adds debounced autosave hook for the current conversation. |
| source/hooks/useAppState.tsx | Adds isSessionSelectorMode + currentSessionId to app state. |
| source/hooks/useAppHandlers.tsx | Adds handlers to enter selector mode and apply selected sessions. |
| source/components/session-selector.tsx | New Ink-based selector UI for resuming sessions. |
| source/app/components/modal-selectors.tsx | Adds session selector as a modal mode. |
| source/app/utils/app-util.ts | Adds /resume//sessions//history slash-command handling. |
| source/commands/resume.ts | Adds a registry command entry (help/info handler). |
| source/commands/index.ts | Exports the new resume command. |
| source/commands/help.tsx | Mentions /resume in the help UI. |
| source/app/App.tsx | Wires in autosave + selector mode rendering (but currently breaks imports). |
| package.json | Adds audit script and security overrides. |
| pnpm-lock.yaml | Applies override/resolution updates for audited packages. |
| docs/session-management.md | Documents session storage, config, and /resume usage. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
source/session/session-manager.ts
Outdated
| const sessionId = Date.now().toString(); | ||
| const timestamp = new Date().toISOString(); | ||
|
|
There was a problem hiding this comment.
Session IDs are generated with Date.now().toString(), which can collide if multiple sessions are created within the same millisecond (e.g., fast clears/resumes or concurrent autosave logic), potentially overwriting an existing session file. Use a collision-resistant ID (e.g., crypto.randomUUID() or timestamp + random suffix).
| await fs.writeFile(sessionFilePath, JSON.stringify(session, null, 2), { | ||
| mode: 0o600, | ||
| }); | ||
|
|
||
| // Update sessions index | ||
| const sessions = await this.listSessions(); | ||
| const existingSessionIndex = sessions.findIndex(s => s.id === session.id); | ||
|
|
||
| const sessionMetadata: SessionMetadata = { | ||
| id: session.id, | ||
| title: session.title, | ||
| createdAt: session.createdAt, | ||
| lastAccessedAt: new Date().toISOString(), | ||
| messageCount: session.messageCount, | ||
| provider: session.provider, | ||
| model: session.model, | ||
| workingDirectory: session.workingDirectory, | ||
| }; | ||
|
|
||
| if (existingSessionIndex >= 0) { | ||
| sessions[existingSessionIndex] = sessionMetadata; | ||
| } else { | ||
| sessions.push(sessionMetadata); | ||
| } | ||
|
|
||
| await fs.writeFile( | ||
| this.sessionsIndexPath, | ||
| JSON.stringify(sessions, null, 2), | ||
| {mode: 0o600}, | ||
| ); | ||
| } |
There was a problem hiding this comment.
fs.writeFile(..., {mode: 0o600}) only applies mode when the file is created; subsequent writes will preserve any existing (possibly too-permissive) mode. For session files and sessions.json, consider enforcing permissions explicitly (e.g., chmod after write) so existing files are corrected as well.
source/session/session-manager.ts
Outdated
| // Update sessions index | ||
| const sessions = await this.listSessions(); | ||
| const existingSessionIndex = sessions.findIndex(s => s.id === session.id); | ||
|
|
||
| const sessionMetadata: SessionMetadata = { | ||
| id: session.id, | ||
| title: session.title, | ||
| createdAt: session.createdAt, | ||
| lastAccessedAt: new Date().toISOString(), | ||
| messageCount: session.messageCount, | ||
| provider: session.provider, | ||
| model: session.model, | ||
| workingDirectory: session.workingDirectory, | ||
| }; | ||
|
|
||
| if (existingSessionIndex >= 0) { | ||
| sessions[existingSessionIndex] = sessionMetadata; | ||
| } else { | ||
| sessions.push(sessionMetadata); | ||
| } | ||
|
|
||
| await fs.writeFile( | ||
| this.sessionsIndexPath, | ||
| JSON.stringify(sessions, null, 2), | ||
| {mode: 0o600}, | ||
| ); |
There was a problem hiding this comment.
saveSession() does a read-modify-write of sessions.json without any locking/serialization. Autosave timers and /resume can call into this concurrently, which can cause lost updates (last writer wins) or inconsistent metadata ordering. Consider serializing writes with a simple mutex/queue inside SessionManager (or writing index updates via a single atomic operation).
| const [loading, setLoading] = useState(true); | ||
| const {} = useApp(); | ||
|
|
There was a problem hiding this comment.
const {} = useApp(); is unused and will trigger noUnusedLocals/lint errors. Remove the useApp import and this line (or use the returned app instance if intended).
| /** | ||
| * Handles /resume, /sessions, /history (session resume). | ||
| * No args: show session selector. One arg: resume by "last", id, or list index. | ||
| * Returns true if handled. | ||
| */ | ||
| async function handleResumeCommand( | ||
| commandParts: string[], | ||
| options: MessageSubmissionOptions, | ||
| ): Promise<boolean> { | ||
| const commandName = commandParts[0]?.toLowerCase(); | ||
| if (!commandName || !isResumeCommand(commandName)) { | ||
| return false; | ||
| } | ||
|
|
||
| const { | ||
| onAddToChatQueue, | ||
| onEnterSessionSelectorMode, | ||
| onResumeSession, | ||
| onCommandComplete, | ||
| getNextComponentKey, | ||
| } = options; | ||
|
|
||
| if (!onEnterSessionSelectorMode || !onResumeSession) { | ||
| onCommandComplete?.(); | ||
| return true; | ||
| } | ||
|
|
||
| const args = commandParts.slice(1); | ||
|
|
||
| // No args: show session selector | ||
| if (args.length === 0) { | ||
| try { | ||
| await sessionManager.initialize(); | ||
| onEnterSessionSelectorMode(); | ||
| } catch (error) { | ||
| onAddToChatQueue( | ||
| React.createElement(ErrorMessage, { | ||
| key: `resume-error-${getNextComponentKey()}`, | ||
| message: `Failed to initialize sessions: ${getErrorMessage(error)}`, | ||
| hideBox: true, | ||
| }), | ||
| ); | ||
| onCommandComplete?.(); | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| // One arg: resolve and load session | ||
| const sessionIdOrSpecial = args[0]; | ||
| try { | ||
| await sessionManager.initialize(); | ||
| const sessions = await sessionManager.listSessions(); | ||
| const sorted = [...sessions].sort( | ||
| (a, b) => | ||
| new Date(b.lastAccessedAt).getTime() - | ||
| new Date(a.lastAccessedAt).getTime(), | ||
| ); | ||
|
|
||
| let sessionId: string | null = null; | ||
|
|
||
| if (sessionIdOrSpecial.toLowerCase() === 'last') { | ||
| if (sorted.length > 0) sessionId = sorted[0].id; | ||
| } else { | ||
| const index = Number.parseInt(sessionIdOrSpecial, 10); | ||
| if (!Number.isNaN(index) && index >= 1 && index <= sorted.length) { | ||
| sessionId = sorted[index - 1].id; | ||
| } else { | ||
| sessionId = sessionIdOrSpecial; | ||
| } | ||
| } | ||
|
|
||
| if (!sessionId) { | ||
| onAddToChatQueue( | ||
| React.createElement(InfoMessage, { | ||
| key: `resume-info-${getNextComponentKey()}`, | ||
| message: 'No sessions found.', | ||
| hideBox: true, | ||
| }), | ||
| ); | ||
| onCommandComplete?.(); | ||
| return true; | ||
| } | ||
|
|
||
| const session = await sessionManager.loadSession(sessionId); | ||
| if (session) { | ||
| onResumeSession(session); | ||
| } else { | ||
| onAddToChatQueue( | ||
| React.createElement(ErrorMessage, { | ||
| key: `resume-error-${getNextComponentKey()}`, | ||
| message: `Session not found: ${sessionId}`, | ||
| hideBox: true, | ||
| }), | ||
| ); | ||
| } | ||
| } catch (error) { | ||
| onAddToChatQueue( | ||
| React.createElement(ErrorMessage, { | ||
| key: `resume-error-${getNextComponentKey()}`, | ||
| message: `Failed to resume session: ${getErrorMessage(error)}`, | ||
| hideBox: true, | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| onCommandComplete?.(); | ||
| return true; | ||
| } |
There was a problem hiding this comment.
handleResumeCommand introduces new command behavior (/resume + aliases, including selector mode and direct resume). There are existing AVA tests for command parsing/handling in this module (app-util.spec.ts), but no tests were added for the new resume behavior. Add tests covering at least: /resume (enter selector mode), /resume last, /resume <n>, invalid session id (error message), and the alias commands (/sessions, /history).
source/hooks/useSessionAutosave.ts
Outdated
| // Initialize session manager | ||
| useEffect(() => { | ||
| const initialize = async () => { | ||
| if (!initializedRef.current) { | ||
| try { | ||
| await sessionManager.initialize(); | ||
| initializedRef.current = true; | ||
| } catch (error) { | ||
| console.warn('Failed to initialize session manager:', error); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| void initialize(); | ||
|
|
||
| return () => { | ||
| if (timeoutRef.current) { | ||
| clearTimeout(timeoutRef.current); | ||
| } | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
useSessionAutosave initializes sessionManager unconditionally on mount, even when sessions.autoSave is disabled. That still creates the sessions directory / index and runs retention cleanup, which may be surprising when autosave is turned off. Consider checking config first and only initializing when autosave is enabled (or when the user explicitly runs /resume).
|
Hey @yashksaini-coder - thanks for this PR - a long overdue feature! :D I see that most PR checks are failing due to |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Fixes Issue: #51
Description
Adds automatic session storage and a /resume command so users can save, list, and restore chat sessions. Sessions are stored under a configurable directory (default ~/.nanocoder-sessions), with an index and one JSON file per session. Autosave keeps a single “current” session updated (debounced); clearing the chat or resuming another session starts a new one. /resume with no args opens an interactive session selector; /resume last, /resume , and /resume resume directly. Aliases /sessions and /history behave like /resume. Resuming restores messages, provider, and model.
Also: security – pnpm overrides for minimatch, markdown-it, ajv, qs, and hono to address audit findings; Copilot – ESM fix in test-copilot.sh, hermetic copilot-credentials spec with NANOCODER_CONFIG_DIR, apiKey: 'dummy-key' for Copilot provider, OAuth naming (pollForOAuthToken / githubOAuthToken), and credential file mode 0o600; lockfile – fix duplicate keys in pnpm-lock.yaml; audit – add pnpm run audit script.
Type of Change
[ ] Bug fix
[x] New feature
[ ] Breaking change
[ ] Documentation update
Testing
[x] New features include passing tests in .spec.ts/tsx files
[x] All existing tests pass (pnpm test:all completes successfully)
[x] Tests cover both success and error scenarios
Checklist
[x] Code follows project style guidelines
[x] Self-review completed
[ ] Documentation updated (if needed)
[x] No breaking changes (or clearly documented)
[x] Appropriate logging added using structured logging (see CONTRIBUTING.md)