Merge branch 'feat/tool-definition-manifest' into dev

Conflicts resolved:
- models/__init__.py: kept both TerminalSessionModel (from dev) and
  ToolDefinitionManifest (from feature branch)
- alembic migration: kept full migration (already applied to DB)
- openspec/config.yaml: kept full config with SDD settings
This commit is contained in:
Alex Blank
2026-05-28 15:49:56 +02:00
35 changed files with 8186 additions and 3065 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"fingerprint": "c324de9e9faf30231900c691aca5f3a07c7db099"
}
+34
View File
@@ -0,0 +1,34 @@
# Skill Registry — headquarter
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
Last updated: 2026-05-27
## Sources scanned
- .opencode/skills
- /home/alex/.config/opencode/skills
## Contract
**Delegator use only.** This registry is an index, not a summary. Any agent that launches subagents reads it to select relevant skills, then passes exact `SKILL.md` paths for the subagent to read before work.
`SKILL.md` remains the source of truth. Do not inject generated summaries or compact rules by default; pass paths so subagents load the full runtime contract and preserve author intent.
## Skills
| Skill | Trigger / description | Scope | Path |
| --- | --- | --- | --- |
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
| `openspec` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/SKILL.md` |
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
## Loading protocol
1. Match task context and target files against the `Trigger / description` column.
2. Pass only the matching `Path` values to the subagent under `## Skills to load before work`.
3. Instruct the subagent to read those exact `SKILL.md` files before reading, writing, reviewing, testing, or creating artifacts.
4. If no matching skill exists, proceed without project skill injection and report `skill_resolution: none`.
+4 -1
View File
@@ -49,5 +49,8 @@ apps/web/dist/
.DS_Store
Thumbs.db
/.stoneforge/.worktrees/
# Local Pi runtime state
# Pi / agent cache
.pi/
.atl/
.sisyphus/
.pi-lens/
@@ -0,0 +1,10 @@
{
"sessionID": "ses_1da2608b1ffergOzow3NQt1mGr",
"updatedAt": "2026-05-15T23:50:42.832Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-05-15T23:50:42.832Z"
}
}
}
+568
View File
@@ -0,0 +1,568 @@
{
"version": "v2",
"timestamp": 1779889907001,
"ruleHash": "fd9b2b15f2ac8993",
"queries": [
{
"id": "bare-except",
"name": "Bare Except Clause",
"severity": "warning",
"language": "python",
"message": "Bare 'except:' clause — catches SystemExit, KeyboardInterrupt",
"query": " (except_clause\n \"except\") @CLAUSE",
"metavars": [
"CLAUSE"
],
"post_filter": "bare_except_only",
"defect_class": "silent-error",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/bare-except.yml"
},
{
"id": "eval-exec",
"name": "Eval/Exec Usage",
"severity": "warning",
"language": "python",
"message": "{{FUNC}}() detected — security risk, code injection vulnerability",
"query": " (call\n function: (identifier) @FUNC\n (#match? @FUNC \"^(eval|exec)$\")\n arguments: (argument_list) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/eval-exec.yml"
},
{
"id": "exit-signature-check",
"name": "__exit__ Missing Parameters",
"severity": "error",
"language": "python",
"message": "__exit__ should accept type, value, and traceback arguments",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__exit__\")\n parameters: (parameters\n (_) @SELF\n . (_) @PARAM1?\n . (_) @PARAM2?\n . (_) @PARAM3?))",
"metavars": [
"NAME",
"SELF",
"PARAM1",
"PARAM2",
"PARAM3"
],
"post_filter": "exit_params_insufficient",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/exit-signature-check.yml"
},
{
"id": "in-operator-unsupported",
"name": "In and Not In Operators Should Be Used on Valid Objects",
"severity": "warning",
"language": "python",
"message": "'in' operator used on object that may not support containment",
"query": " (comparison_operator\n (identifier) @OBJ\n \"in\"\n (identifier) @TARGET)\n (comparison_operator\n (identifier) @OBJ\n \"not\"\n \"in\"\n (identifier) @TARGET)",
"metavars": [
"OBJ",
"TARGET"
],
"post_filter": "check_in_operator_types",
"defect_class": "correctness",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/in-operator-unsupported.yml"
},
{
"id": "is-vs-equals",
"name": "Is vs Equals for Literals",
"severity": "warning",
"language": "python",
"message": "Using 'is' with literal — use '==' for value comparison",
"query": " (comparison_operator\n (identifier)\n (\"is\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is\")\n (integer) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (integer) @LITERAL)",
"metavars": [
"LITERAL"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/is-vs-equals.yml"
},
{
"id": "iter-return-iterator",
"name": "__iter__ Should Return Iterator",
"severity": "warning",
"language": "python",
"message": "__iter__ should return an iterator (object with __next__ method)",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__iter__\")\n body: (block\n (return_statement) @RETURN))",
"metavars": [
"NAME",
"RETURN"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/iter-return-iterator.yml"
},
{
"id": "mutable-default-arg",
"name": "Mutable Default Argument",
"severity": "warning",
"language": "python",
"message": "Mutable default argument — list/dict/set as default value",
"query": " (function_definition\n (parameters\n (default_parameter\n (identifier) @PARAM\n [(list) (dictionary) (set)] @MUTABLE)))",
"metavars": [
"PARAM",
"MUTABLE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/mutable-default-arg.yml"
},
{
"id": "no-super-torchscript",
"name": "super Should Not Be Used in TorchScript Methods",
"severity": "error",
"language": "python",
"message": "super() calls should not be used in TorchScript methods",
"query": " (function_definition\n (decorator\n (call\n function: (identifier) @DEC (#match? @DEC \"^(torch\\.jit\\.script|jit\\.script)$\")))\n body: (block\n (call\n function: (identifier) @FUNC (#eq? @FUNC \"super\")) @CALL))",
"metavars": [
"DEC",
"FUNC",
"CALL"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/no-super-torchscript.yml"
},
{
"id": "notimplemented-boolean-context",
"name": "NotImplemented in Boolean Context",
"severity": "error",
"language": "python",
"message": "NotImplemented should not be used in boolean contexts",
"query": " (if_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (while_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (binary_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\")\n (\"and\" | \"or\"))\n (boolean_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (unary_operator\n operator: (\"not\")\n argument: (identifier) @COND (#eq? @COND \"NotImplemented\"))",
"metavars": [
"COND"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml"
},
{
"id": "python-assert-production",
"name": "Assert in Production Code",
"severity": "warning",
"language": "python",
"message": "assert statement stripped by Python -O flag — use explicit checks with exceptions in production code",
"query": " (assert_statement) @ASSERT",
"metavars": [
"ASSERT"
],
"defect_class": "correctness",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-assert-production.yml"
},
{
"id": "python-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "python",
"message": "Potential command injection sink — avoid shell execution with dynamic input",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"os\")\n (#match? @FN \"^(system|popen)$\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true)))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
"metavars": [
"MOD",
"FN",
"ARGS",
"KW"
],
"post_filter": "py_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-command-injection.yml"
},
{
"id": "python-cross-language-method",
"name": "Cross-Language Method Leakage",
"severity": "warning",
"language": "python",
"message": "'{METHOD}' is not a Python method — likely a {LANG} idiom leaking in",
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @METHOD)\n (#match? @METHOD \"^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$\"))",
"metavars": [
"OBJ",
"METHOD"
],
"post_filter": "match_captures",
"post_filter_params": {
"METHOD": "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"
},
"defect_class": "hallucination",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml"
},
{
"id": "python-debugger",
"name": "Debugger Statement",
"severity": "warning",
"language": "python",
"message": "Debugger call '{{FUNC}}' — remove before committing",
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"breakpoint\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n (#eq? @MOD \"pdb\")\n (#match? @FUNC \"^(set_trace|post_mortem|pm|run|runcall)$\"))",
"metavars": [
"FUNC",
"MOD"
],
"defect_class": "safety",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-debugger.yml"
},
{
"id": "python-empty-except",
"name": "Empty Except Block",
"severity": "warning",
"language": "python",
"message": "Except block only contains 'pass' — handle or re-raise the exception",
"query": " (try_statement\n (except_clause\n body: (block) @BODY))",
"metavars": [
"BODY"
],
"post_filter": "python_empty_except",
"defect_class": "silent-error",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-empty-except.yml"
},
{
"id": "python-hallucinated-import",
"name": "Hallucinated Import",
"severity": "warning",
"language": "python",
"message": "Hallucinated import — '{NAME}' does not exist in '{MODULE}'",
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n name: (dotted_name) @NAME)",
"metavars": [
"MODULE",
"NAME"
],
"post_filter": "match_captures",
"post_filter_params": {
"MODULE": "^(requests|flask|django|typing|collections|asyncio|json|unittest|pytest|urllib|sqlalchemy)$",
"NAME": "^(JSONResponse|HTMLResponse|RedirectResponse|StreamingResponse|Depends|Query|Path|Body|Header|Cookie|Form|File|UploadFile|FastAPI|APIRouter|HTTPException|BackgroundTasks|dataclass|fields|BaseModel|Field|validator|aiohttp|parse|stringify|fixture|TestCase|get|post|put|delete|Model|Session|Column|Integer|String)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml"
},
{
"id": "python-hardcoded-secrets",
"name": "Hardcoded Secret",
"severity": "warning",
"language": "python",
"message": "Hardcoded {{VARNAME}} — use environment variables or a secrets manager",
"query": " (assignment\n left: (identifier) @VARNAME\n right: (string) @VALUE)",
"metavars": [
"VARNAME",
"VALUE"
],
"post_filter": "check_secret_pattern",
"defect_class": "secrets",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml"
},
{
"id": "python-insecure-deserialization",
"name": "Insecure Deserialization",
"severity": "error",
"language": "python",
"message": "Potential insecure deserialization sink — avoid unsafe loaders",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list (_) @DATA)\n (#match? @MOD \"^(pickle|yaml)$\")\n (#match? @FN \"^(load|loads|unsafe_load)$\"))",
"metavars": [
"MOD",
"FN",
"DATA"
],
"post_filter": "py_insecure_deserialization_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-deserialization.yml"
},
{
"id": "python-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "python",
"message": "Insecure randomness source detected — use secrets or os.urandom for security-sensitive values",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"random\")\n (#match? @FN \"^(random|randint|randrange|choice|choices)$\"))",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-random.yml"
},
{
"id": "python-mutable-class-attr",
"name": "Mutable Class Attribute",
"severity": "warning",
"language": "python",
"message": "Class attribute '{{VARNAME}}' is mutable — shared across all instances",
"query": " (class_definition\n body: (block\n (expression_statement\n (assignment\n left: (identifier) @VARNAME\n right: [\n (list) @VALUE\n (dictionary) @VALUE\n (set) @VALUE\n ]))))",
"metavars": [
"VARNAME",
"VALUE"
],
"post_filter": "not_in_function",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-mutable-class-attr.yml"
},
{
"id": "python-path-traversal",
"name": "Path Traversal Risk",
"severity": "warning",
"language": "python",
"message": "Potential path traversal sink — sanitize and constrain file paths",
"query": " [\n (call\n function: (identifier) @FN\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n ]\n (#match? @FN \"^(open|read_text|read_bytes|write_text|write_bytes|remove|unlink|rmdir)$\")",
"metavars": [
"MOD",
"FN",
"PATH"
],
"post_filter": "py_path_traversal_sink",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-path-traversal.yml"
},
{
"id": "python-print-statement",
"name": "Print Statement in Production",
"severity": "warning",
"language": "python",
"message": "print() — remove debug output before committing",
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"print\")\n arguments: (argument_list) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"post_filter": "not_in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-print-statement.yml"
},
{
"id": "python-raise-string",
"name": "Raise String Instead of Exception",
"severity": "warning",
"language": "python",
"message": "raise with string literal — Python 3 requires exception instances",
"query": " (raise_statement\n (string) @VALUE)",
"metavars": [
"VALUE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-raise-string.yml"
},
{
"id": "python-sleep-in-test",
"name": "time.sleep in Test",
"severity": "warning",
"language": "python",
"message": "time.sleep() in test — use synchronisation primitives or polling helpers instead of fixed sleeps",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n (#eq? @MOD \"time\")\n (#eq? @FN \"sleep\")) @CALL",
"metavars": [
"MOD",
"FN",
"CALL"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sleep-in-test.yml"
},
{
"id": "python-sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "python",
"message": "Potential SQL injection sink — use parameterized queries",
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(binary_operator) (identifier) (call)] @SQL\n (_)*))",
"metavars": [
"OBJ",
"FN",
"SQL"
],
"post_filter": "py_sql_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml"
},
{
"id": "python-ssrf",
"name": "SSRF Risk",
"severity": "warning",
"language": "python",
"message": "Potential SSRF sink — validate/allowlist outbound URLs",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (subscript) (call)] @URL)\n (#eq? @MOD \"requests\")\n (#match? @FN \"^(get|post|put|patch|delete|request|head|options)$\"))",
"metavars": [
"MOD",
"FN",
"URL"
],
"post_filter": "py_ssrf_sink",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-ssrf.yml"
},
{
"id": "python-subprocess-shell",
"name": "subprocess with shell=True",
"severity": "warning",
"language": "python",
"message": "subprocess called with shell=True — command injection risk if any argument is user-controlled",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true) @VAL))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
"metavars": [
"MOD",
"FN",
"KW"
],
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-subprocess-shell.yml"
},
{
"id": "python-thread-global-write",
"name": "Threaded Shared State Risk",
"severity": "warning",
"language": "python",
"message": "Thread creation detected — ensure shared state mutations are synchronized",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS)\n (#eq? @MOD \"threading\")\n (#eq? @FN \"Thread\")",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-thread-global-write.yml"
},
{
"id": "python-unsafe-regex",
"name": "Unsafe Dynamic Regex",
"severity": "warning",
"language": "python",
"message": "re.{{FUNC}}() with variable pattern — ReDoS risk if pattern is user-controlled",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n arguments: (argument_list\n (identifier) @PATTERN)\n (#eq? @MOD \"re\")\n (#match? @FUNC \"^(compile|match|search|fullmatch|findall|finditer|sub|subn|split)$\"))",
"metavars": [
"MOD",
"FUNC",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-unsafe-regex.yml"
},
{
"id": "python-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "python",
"message": "Weak hash primitive detected (MD5/SHA1) — use SHA-256+ for security-sensitive contexts",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"hashlib\")\n (#match? @FN \"^(md5|sha1)$\"))",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-weak-hash.yml"
},
{
"id": "return-in-generator",
"name": "Return with Value in Generator",
"severity": "error",
"language": "python",
"message": "'return' with a value should not be used in a generator function",
"query": " (function_definition\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN)) @FUNCTION",
"metavars": [
"FUNCTION",
"RETURN",
"RETURN_VAL"
],
"post_filter": "is_generator_with_valued_return",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-generator.yml"
},
{
"id": "return-in-init",
"name": "Return Value in __init__",
"severity": "error",
"language": "python",
"message": "__init__ should not return a value — it must always return None",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__init__\")\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN))",
"metavars": [
"NAME",
"RETURN",
"RETURN_VAL"
],
"post_filter": "has_return_value",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-init.yml"
},
{
"id": "send-file-mimetype",
"name": "send_file Should Specify Mimetype or Download Name",
"severity": "error",
"language": "python",
"message": "send_file should specify 'mimetype' or 'download_name' when used with file-like objects",
"query": " (call\n function: (identifier) @FUNC (#eq? @FUNC \"send_file\")\n arguments: (argument_list\n (_) @FIRST_ARG\n (keyword_argument)? @KW))",
"metavars": [
"FUNC",
"FIRST_ARG",
"KW"
],
"post_filter": "missing_mimetype_and_download_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/send-file-mimetype.yml"
},
{
"id": "unreachable-except",
"name": "Unreachable Except Clause",
"severity": "warning",
"language": "python",
"message": "Unreachable except clause — earlier except catches all",
"query": " (try_statement\n (except_clause\n \"except\") @GENERAL\n (except_clause\n \"except\"\n (identifier) @SPECIFIC))",
"metavars": [
"GENERAL",
"SPECIFIC"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/unreachable-except.yml"
},
{
"id": "wildcard-import",
"name": "Wildcard Import",
"severity": "warning",
"language": "python",
"message": "Wildcard import — pollutes namespace, hard to track origin",
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n (wildcard_import) @WILDCARD)",
"metavars": [
"MODULE",
"WILDCARD"
],
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/wildcard-import.yml"
},
{
"id": "yield-return-outside-function",
"name": "Yield/Return Outside Function",
"severity": "error",
"language": "python",
"message": "{{STATEMENT}} used outside function — syntax error",
"query": " (module\n (expression_statement\n (yield) @STATEMENT))\n (module\n (expression_statement\n (yield_expression) @STATEMENT))\n (module\n (return_statement) @STATEMENT)",
"metavars": [
"STATEMENT"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/yield-return-outside-function.yml"
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
"""add terminal_sessions table
Revision ID: 2026_05_28_add_terminal_sessions
Revises: 20260527_160017_add_pi_agent
Create Date: 2026-05-28
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_28_add_terminal_sessions"
down_revision: str | None = "2026_05_28_add_tool_definition_manifests"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"terminal_sessions",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("instance_id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
onupdate=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["instance_id"], ["tool_instances.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_terminal_sessions_instance_id"),
"terminal_sessions",
["instance_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_terminal_sessions_instance_id"),
table_name="terminal_sessions",
)
op.drop_table("terminal_sessions")
+470 -72
View File
@@ -1,16 +1,20 @@
"""WebSocket terminal endpoint for tool instances."""
import asyncio
import json
import logging
import uuid
from contextlib import suppress
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.terminal_session import TerminalSessionModel
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.terminal_manager import terminal_manager
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -19,32 +23,58 @@ logger = logging.getLogger(__name__)
class SessionRef:
"""Mutable reference to a terminal session, allowing updates during reset."""
def __init__(self, session):
def __init__(self, session, slot_session_id: str | None = None):
self.session = session
self.slot_session_id = slot_session_id or session.session_id
@router.websocket(
"/ws/tool-instances/{instance_id}/terminal",
)
async def terminal_websocket(
async def terminal_websocket_default(
websocket: WebSocket,
instance_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
"""WebSocket endpoint for terminal access to a tool instance.
"""WebSocket endpoint for terminal access (default session alias).
Provides an interactive terminal session inside a running tool instance container.
Sessions persist across WebSocket disconnections.
Backward-compatible route that maps to the default session.
"""
await _handle_terminal_websocket(websocket, instance_id, None, db_session)
@router.websocket(
"/ws/tool-instances/{instance_id}/terminal/{session_id}",
)
async def terminal_websocket_specific(
websocket: WebSocket,
instance_id: str,
session_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
"""WebSocket endpoint for a specific terminal session."""
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
async def _handle_terminal_websocket(
websocket: WebSocket,
instance_id: str,
target_session_id: str | None,
db_session: AsyncSession,
) -> None:
"""Shared WebSocket handler for terminal sessions.
Args:
websocket: The WebSocket connection.
instance_id: UUID string of the tool instance.
target_session_id: Specific session ID (slot key). None means default session.
db_session: Database session.
Returns:
None. Communicates via WebSocket messages.
"""
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
logger.debug(
"Terminal WebSocket connection attempt for instance %s (session=%s)",
instance_id,
target_session_id or "default",
)
await websocket.accept()
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
@@ -59,7 +89,9 @@ async def terminal_websocket(
# Authenticate user from session cookie
user_id = await _get_user_from_websocket(websocket, db_session)
if user_id is None:
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
logger.warning(
"Unauthorized terminal access attempt for instance %s", instance_id
)
await websocket.close(code=4003, reason="Unauthorized")
return
@@ -71,12 +103,21 @@ async def terminal_websocket(
return
if instance.owner_id != user_id:
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
logger.warning(
"Forbidden terminal access for instance %s by user %s",
instance_id,
user_id,
)
await websocket.close(code=4003, reason="Forbidden")
return
if instance.status != "running" or not instance.container_id:
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
logger.warning(
"Instance %s not running (status=%s, container_id=%s)",
instance_id,
instance.status,
instance.container_id,
)
await websocket.close(code=4004, reason="Instance not running")
return
@@ -86,16 +127,50 @@ async def terminal_websocket(
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
if startup_command:
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
logger.debug(
"Using startup command for instance %s: %s",
instance_id,
startup_command,
)
session = None
# Get or create terminal session
try:
session = await terminal_manager.get_or_create_session(
instance_uuid,
instance.container_id,
startup_command=startup_command,
if target_session_id is None:
# Default session alias
session = await terminal_manager.get_or_create_session(
instance_uuid,
instance.container_id,
startup_command=startup_command,
)
slot_session_id = "default"
else:
# Specific session
session = terminal_manager.get_session(
instance_id,
target_session_id,
)
if session is None:
logger.warning(
"Session %s not found for instance %s",
target_session_id,
instance_id,
)
await websocket.close(code=4004, reason="Session not found")
return
# Determine slot key for reset scoping
key = terminal_manager._find_key_by_internal_id(
instance_id, session.session_id
)
slot_session_id = key[1] if key else target_session_id
logger.debug(
"Terminal session ready for instance %s (session_id=%s, slot=%s)",
instance_id,
session.session_id,
slot_session_id,
)
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
# Attach WebSocket to session
await terminal_manager.attach_websocket(session, websocket)
@@ -106,11 +181,13 @@ async def terminal_websocket(
logger.debug("Sent connected status for instance %s", instance_id)
# Use mutable session reference so loops can survive reset
session_ref = SessionRef(session)
session_ref = SessionRef(session, slot_session_id)
# Start I/O loops and heartbeat
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
write_task = asyncio.create_task(
_write_loop(session_ref, websocket, instance_id)
)
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
logger.debug("Started terminal loops for instance %s", instance_id)
@@ -119,24 +196,33 @@ async def terminal_websocket(
[read_task, write_task, heartbeat_task],
return_when=asyncio.FIRST_COMPLETED,
)
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
logger.debug(
"Terminal loop completed for instance %s, done=%s",
instance_id,
len(done),
)
# Cancel remaining tasks
for task in pending:
task.cancel()
except Exception as exc:
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
logger.error(
"Terminal session error for instance %s: %s",
instance_id,
str(exc),
exc_info=True,
)
await websocket.close(code=4000, reason=f"Error: {exc}")
finally:
# Detach WebSocket, don't kill session
try:
if 'session' in locals():
with suppress(Exception):
if session is not None:
await terminal_manager.detach_websocket(session, websocket)
logger.debug("WebSocket detached from session for instance %s", instance_id)
except Exception:
pass
logger.debug(
"WebSocket detached from session for instance %s", instance_id
)
async def _read_loop(session_ref: SessionRef, websocket) -> None:
@@ -175,38 +261,54 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
msg_type = ctrl.get("type")
if msg_type == "resize":
cols = ctrl.get("cols", 80)
rows = ctrl.get("rows", 24)
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
logger.debug(
"Received resize message for instance %s: %sx%s",
instance_id,
cols,
rows,
)
await session.resize(cols, rows)
elif msg_type == "reset":
# Reset terminal session
logger.debug("Resetting terminal session for instance %s", session.instance_id)
await websocket.send_json({"type": "status", "status": "resetting"})
# Reset the session
# Reset terminal session (scoped to current slot)
logger.debug(
"Resetting terminal session for instance %s (slot=%s)",
session.instance_id,
session_ref.slot_session_id,
)
await websocket.send_json(
{"type": "status", "status": "resetting"}
)
# Reset the session scoped to its slot
new_session = await terminal_manager.reset_session(
session.instance_id,
session.container_id,
startup_command=session.startup_command,
session_id=session_ref.slot_session_id,
name=session.name,
)
# Update the mutable session reference so read_loop uses the new session
# Update the mutable session reference
session_ref.session = new_session
# Attach to new session
await terminal_manager.attach_websocket(new_session, websocket)
await websocket.send_json({"type": "status", "status": "connected"})
await terminal_manager.attach_websocket(
new_session, websocket
)
await websocket.send_json(
{"type": "status", "status": "connected"}
)
# Continue the loop with the new session
continue
except json.JSONDecodeError:
# Not a valid JSON control message, treat as regular input
await session.write_input(text.encode("utf-8"))
@@ -232,56 +334,347 @@ async def _heartbeat_loop(websocket: WebSocket) -> None:
pass
@router.post(
"/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset",
summary="Reset terminal session",
description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.",
)
async def reset_terminal_session(
project_id: uuid.UUID,
repo_id: uuid.UUID,
async def _get_terminal_instance(
instance_id: uuid.UUID,
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Reset the terminal session for an instance.
user_id: uuid.UUID,
db_session: AsyncSession,
) -> ToolInstance:
"""Fetch instance and validate auth, ownership, and running status.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the tool instance.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with status message.
The validated ToolInstance.
Raises:
HTTPException: If instance not found, not owned, or not running.
"""
# Get instance and verify it exists and is running
instance = await db_session.get(ToolInstance, instance_id)
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found"
)
if instance.owner_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this instance",
)
if instance.status != "running" or not instance.container_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Instance is not running"
status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running"
)
return instance
@router.get(
"/instances/{instance_id}/terminal/sessions",
summary="List terminal sessions",
description="List terminal sessions for a tool instance with live WebSocket state.",
)
async def list_terminal_sessions(
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List terminal sessions for an instance.
Args:
instance_id: UUID of the tool instance.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with sessions list.
"""
await _get_terminal_instance(instance_id, user_id, db_session)
# Query active DB rows for this instance
result = await db_session.execute(
select(TerminalSessionModel)
.where(TerminalSessionModel.instance_id == instance_id)
.where(TerminalSessionModel.status != "closed")
.order_by(TerminalSessionModel.created_at.asc())
)
db_rows = result.scalars().all()
# Build response with live has_websockets flag
sessions = []
for row in db_rows:
live_session = terminal_manager.get_session(str(instance_id), str(row.id))
sessions.append(
{
"id": str(row.id),
"name": row.name,
"status": row.status,
"has_websockets": live_session.has_websockets()
if live_session
else False,
"created_at": row.created_at.isoformat() if row.created_at else None,
"last_activity_at": row.last_activity_at.isoformat()
if row.last_activity_at
else None,
}
)
return {"sessions": sessions}
@router.post(
"/instances/{instance_id}/terminal/sessions",
summary="Create terminal session",
description="Create a new terminal session for a running tool instance.",
status_code=status.HTTP_201_CREATED,
)
async def create_terminal_session(
instance_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a new terminal session.
Args:
instance_id: UUID of the tool instance.
data: Request body with optional name.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with new session details.
Raises:
HTTPException: 409 if max sessions reached.
"""
instance = await _get_terminal_instance(instance_id, user_id, db_session)
assert instance.container_id is not None
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
name = data.get("name")
try:
session = await terminal_manager.create_session(
instance_id,
instance.container_id,
startup_command=startup_command,
name=name,
)
except MaxSessionsExceededError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Maximum of 5 terminal sessions reached for this instance",
) from None
return {
"id": session.session_id,
"name": session.name,
"status": session.status,
"created_at": session.last_activity,
}
@router.delete(
"/instances/{instance_id}/terminal/sessions/{session_id}",
summary="Close terminal session",
description="Close a specific terminal session.",
)
async def close_terminal_session(
instance_id: uuid.UUID,
session_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Close a terminal session.
Args:
instance_id: UUID of the tool instance.
session_id: ID of the session to close.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with closure status.
"""
await _get_terminal_instance(instance_id, user_id, db_session)
# Find the session by internal ID to determine its slot key
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
if (
key is None
and terminal_manager.get_session(str(instance_id), session_id) is not None
):
key = (str(instance_id), session_id)
if key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
)
await terminal_manager.close_session(key[0], key[1])
return {"status": "closed", "session_id": session_id}
@router.post(
"/instances/{instance_id}/terminal/sessions/{session_id}/reset",
summary="Reset terminal session",
description="Reset a specific terminal session, killing the current shell and starting fresh.",
)
async def reset_specific_terminal_session(
instance_id: uuid.UUID,
session_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Reset a specific terminal session.
Args:
instance_id: UUID of the tool instance.
session_id: ID of the session to reset.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with reset session details.
"""
instance = await _get_terminal_instance(instance_id, user_id, db_session)
assert instance.container_id is not None
# Determine slot key for reset
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
if (
key is None
and terminal_manager.get_session(str(instance_id), session_id) is not None
):
key = (str(instance_id), session_id)
if key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
# Preserve name if possible
live_session = terminal_manager.get_session(str(instance_id), session_id)
name = live_session.name if live_session else None
new_session = await terminal_manager.reset_session(
instance_id,
instance.container_id,
startup_command=startup_command,
session_id=key[1],
name=name,
)
return {
"id": new_session.session_id,
"name": new_session.name,
"status": new_session.status,
}
@router.post(
"/instances/{instance_id}/terminal/sessions/{session_id}/rename",
summary="Rename terminal session",
description="Rename a specific terminal session.",
)
async def rename_terminal_session(
instance_id: uuid.UUID,
session_id: str,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Rename a terminal session.
Args:
instance_id: UUID of the tool instance.
session_id: ID of the session to rename.
data: Request body with new name.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with updated session details.
"""
await _get_terminal_instance(instance_id, user_id, db_session)
new_name = data.get("name")
if not new_name or not isinstance(new_name, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
)
# Update in-memory session name if live
live_session = terminal_manager.get_session(str(instance_id), session_id)
if live_session:
live_session.name = new_name
# Update DB row
db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id))
if db_row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
)
db_row.name = new_name
await db_session.commit()
return {"id": str(db_row.id), "name": new_name}
@router.post(
"/instances/{instance_id}/terminal/reset",
summary="Reset terminal session (legacy alias)",
description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.",
)
async def reset_terminal_session(
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Reset the default terminal session for an instance (legacy alias).
Args:
instance_id: UUID of the tool instance.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with status message.
"""
instance = await _get_terminal_instance(instance_id, user_id, db_session)
assert instance.container_id is not None
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
try:
# Reset the session
# Reset the default session
new_session = await terminal_manager.reset_session(
instance_id,
instance.container_id,
startup_command=startup_command,
)
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
logger.info(
"Terminal session reset for instance %s (new session_id=%s)",
instance_id,
new_session.session_id,
)
return {
"status": "success",
"message": "Terminal session reset successfully",
@@ -289,11 +682,16 @@ async def reset_terminal_session(
"session_id": new_session.session_id,
}
except Exception as exc:
logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True)
logger.error(
"Failed to reset terminal session for instance %s: %s",
instance_id,
str(exc),
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset terminal session: {exc}"
)
detail=f"Failed to reset terminal session: {exc}",
) from exc
async def _get_user_from_websocket(
+1
View File
@@ -25,6 +25,7 @@ from src.api.tool_types import router as tool_types_router
from src.api.user_config import router as user_config_router
from src.api.users import router as users_router
from src.config import Settings
from src.models.terminal_session import TerminalSessionModel # noqa: F401 Alembic model discovery
from src.database import init_database
from src.logging_config import (
ExceptionLoggingMiddleware,
+2
View File
@@ -4,6 +4,7 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.terminal_session import TerminalSessionModel
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
@@ -18,6 +19,7 @@ __all__ = [
"GitRepository",
"Project",
"SSHKey",
"TerminalSessionModel",
"ToolDefinitionManifest",
"ToolInstance",
"ToolType",
+37
View File
@@ -0,0 +1,37 @@
"""Terminal session database model."""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Database model for terminal session metadata."""
__tablename__ = "terminal_sessions"
instance_id: Mapped[uuid.UUID] = mapped_column(
UUID(),
ForeignKey("tool_instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="active",
)
last_activity_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
+300 -46
View File
@@ -3,20 +3,37 @@
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from fastapi import WebSocket
from src.database import SessionLocal
from src.models.terminal_session import TerminalSessionModel
from src.services.terminal_session import TerminalSession
logger = logging.getLogger(__name__)
class MaxSessionsExceededError(Exception):
"""Raised when the maximum number of terminal sessions per instance is reached."""
def __init__(self, instance_id: str, max_sessions: int = 5) -> None:
self.instance_id = instance_id
self.max_sessions = max_sessions
super().__init__(
f"Maximum of {max_sessions} terminal sessions reached for instance {instance_id}"
)
class TerminalManager:
"""Manages active terminal sessions with persistence support."""
# Maximum sessions per tool instance
MAX_SESSIONS_PER_INSTANCE = 5
def __init__(self) -> None:
# Track sessions by instance_id for persistence
self._sessions: dict[str, TerminalSession] = {}
# Track sessions by (instance_id, session_id) for multi-session support
self._sessions: dict[tuple[str, str], TerminalSession] = {}
self._idle_check_task: asyncio.Task | None = None
self._start_idle_check()
@@ -42,16 +59,131 @@ class TerminalManager:
async def _cleanup_idle_sessions(self) -> None:
"""Clean up sessions that have been idle for too long."""
idle_sessions = []
for instance_id, session in list(self._sessions.items()):
idle_keys = []
for (instance_id, session_id), session in list(self._sessions.items()):
if session.is_idle():
idle_sessions.append(instance_id)
for instance_id in idle_sessions:
logger.info("Cleaning up idle terminal session for instance %s", instance_id)
session = self._sessions.pop(instance_id, None)
idle_keys.append((instance_id, session_id))
for key in idle_keys:
instance_id, session_id = key
logger.info(
"Cleaning up idle terminal session %s for instance %s",
session_id,
instance_id,
)
session = self._sessions.pop(key, None)
if session:
await session.close()
# Update DB status fire-and-forget
asyncio.create_task(self._mark_closed_in_db(session_id))
async def _insert_db_session_row(
self,
session_id: str,
instance_id: uuid.UUID,
name: str,
) -> None:
"""Insert a TerminalSessionModel row into the database."""
try:
async with SessionLocal() as db_session:
db_row = TerminalSessionModel(
id=uuid.UUID(session_id),
instance_id=instance_id,
name=name,
status="active",
created_at=datetime.now(timezone.utc),
last_activity_at=datetime.now(timezone.utc),
)
db_session.add(db_row)
await db_session.commit()
logger.debug(
"Inserted terminal session row %s for instance %s",
session_id,
instance_id,
)
except Exception as exc:
logger.error("Failed to insert terminal session row: %s", exc)
async def _mark_closed_in_db(self, session_id: str) -> None:
"""Mark a terminal session as closed in the database."""
try:
async with SessionLocal() as db_session:
db_row = await db_session.get(
TerminalSessionModel, uuid.UUID(session_id)
)
if db_row:
db_row.status = "closed"
db_row.closed_at = datetime.now(timezone.utc)
await db_session.commit()
logger.debug(
"Marked terminal session %s as closed in DB", session_id
)
except Exception as exc:
logger.error("Failed to mark terminal session as closed in DB: %s", exc)
def _count_sessions_for_instance(self, instance_id_str: str) -> int:
"""Count active in-memory sessions for a given instance."""
return sum(1 for (iid, _sid) in self._sessions if iid == instance_id_str)
async def create_session(
self,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
name: str | None = None,
) -> TerminalSession:
"""Create a new terminal session for an instance.
Enforces a maximum of MAX_SESSIONS_PER_INSTANCE sessions per instance.
Inserts a DB row fire-and-forget.
Args:
instance_id: UUID of the tool instance.
container_id: Docker container ID.
startup_command: Optional startup command to run.
name: Optional session name (auto-generated if omitted).
Returns:
The newly created TerminalSession.
Raises:
MaxSessionsExceededError: If the instance already has max sessions.
"""
instance_id_str = str(instance_id)
if (
self._count_sessions_for_instance(instance_id_str)
>= self.MAX_SESSIONS_PER_INSTANCE
):
raise MaxSessionsExceededError(
instance_id_str, self.MAX_SESSIONS_PER_INSTANCE
)
session_id = str(uuid.uuid4())
session = TerminalSession(
session_id=session_id,
instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name=name,
)
await session.start(startup_command=startup_command)
key = (instance_id_str, session_id)
self._sessions[key] = session
# Fire-and-forget DB insert
asyncio.create_task(
self._insert_db_session_row(session_id, instance_id, session.name)
)
logger.info(
"Created terminal session %s for instance %s (name=%s)",
session_id,
instance_id,
session.name,
)
return session
async def get_or_create_session(
self,
@@ -59,61 +191,146 @@ class TerminalManager:
container_id: str,
startup_command: str | None = None,
) -> TerminalSession:
"""Get existing session or create a new one."""
"""Get existing session or create a new one.
Backward-compatible alias that uses 'default' as the session_id.
"""
# Ensure idle check is running (lazy start)
self._start_idle_check()
instance_id_str = str(instance_id)
# Check for existing session
if instance_id_str in self._sessions:
session = self._sessions[instance_id_str]
key = (instance_id_str, "default")
# Check for existing default session
if key in self._sessions:
session = self._sessions[key]
# Check if session is still alive
if session.is_alive():
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
logger.debug(
"Reattaching to existing terminal session for instance %s",
instance_id,
)
return session
else:
# Session died, clean it up
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
logger.debug(
"Existing session for instance %s is dead, cleaning up",
instance_id,
)
await session.close()
del self._sessions[instance_id_str]
# Create new session
logger.info("Creating new terminal session for instance %s", instance_id)
del self._sessions[key]
# Create new default session
logger.info(
"Creating new default terminal session for instance %s", instance_id
)
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
session = TerminalSession(
session_id=session_id,
instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name="Session 1",
)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
self._sessions[key] = session
# Fire-and-forget DB insert
asyncio.create_task(
self._insert_db_session_row(session_id, instance_id, session.name)
)
return session
def get_session(
self,
instance_id: str,
session_id: str,
) -> TerminalSession | None:
"""Lookup a session by composite key, or by internal session_id."""
session = self._sessions.get((instance_id, session_id))
if session is not None:
return session
# Fallback: search by internal TerminalSession.session_id
for (iid, _sid), sess in self._sessions.items():
if iid == instance_id and sess.session_id == session_id:
return sess
return None
def _find_key_by_internal_id(
self,
instance_id: str,
internal_session_id: str,
) -> tuple[str, str] | None:
"""Find the manager dict key for a session by its internal session_id."""
for (iid, sid), session in self._sessions.items():
if iid == instance_id and session.session_id == internal_session_id:
return (iid, sid)
return None
def get_sessions_for_instance(
self,
instance_id: str,
) -> list[TerminalSession]:
"""Return all in-memory sessions for a given instance."""
return [
session
for (iid, _sid), session in self._sessions.items()
if iid == instance_id
]
async def close_session(
self,
instance_id: str,
session_id: str,
) -> None:
"""Close a specific session and update its DB status."""
key = (instance_id, session_id)
session = self._sessions.pop(key, None)
if session:
await session.close()
# Fire-and-forget DB update
asyncio.create_task(self._mark_closed_in_db(session_id))
logger.info(
"Closed terminal session %s for instance %s",
session_id,
instance_id,
)
async def attach_websocket(
self,
session: TerminalSession,
websocket: WebSocket,
) -> None:
"""Attach a WebSocket to an existing session."""
# Handle concurrent connections - close existing ones
"""Attach a WebSocket to an existing session.
Closes existing WebSocket connections only for this specific session.
"""
# Handle concurrent connections - close existing ones within the same session
if session.has_websockets():
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
logger.debug(
"Closing existing WebSocket connections for session %s (instance %s)",
session.session_id,
session.instance_id,
)
for ws in list(session._websockets):
try:
await ws.close(code=4000, reason="New connection established")
except Exception:
pass
pass # noqa: S110
session._websockets.clear()
# Attach new WebSocket
session.attach_websocket(websocket)
# Replay buffer
buffer = session.get_buffer()
if buffer:
try:
await websocket.send_bytes(buffer)
except Exception:
pass
pass # noqa: S110
async def detach_websocket(
self,
@@ -128,23 +345,60 @@ class TerminalManager:
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
session_id: str | None = None,
name: str | None = None,
) -> TerminalSession:
"""Reset a session by killing it and creating a new one."""
"""Reset a session by killing it and creating a new one.
Args:
instance_id: UUID of the tool instance.
container_id: Docker container ID.
startup_command: Optional startup command.
session_id: Specific session to reset. If None, resets the default session.
name: Optional name to preserve for the new session.
Returns:
The newly created TerminalSession.
"""
instance_id_str = str(instance_id)
target_session_id = session_id or "default"
key = (instance_id_str, target_session_id)
# Preserve old name if not provided
old_name = name
if old_name is None and key in self._sessions:
old_name = self._sessions[key].name
# Close existing session if any
if instance_id_str in self._sessions:
logger.debug("Resetting terminal session for instance %s", instance_id)
old_session = self._sessions.pop(instance_id_str)
if key in self._sessions:
logger.debug(
"Resetting terminal session %s for instance %s",
target_session_id,
instance_id,
)
old_session = self._sessions.pop(key)
await old_session.close()
# Create new session
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
return session
# Fire-and-forget DB update for old session
asyncio.create_task(self._mark_closed_in_db(old_session.session_id))
# Create new session preserving the same session_id slot
new_session_id = str(uuid.uuid4())
new_session = TerminalSession(
session_id=new_session_id,
instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name=old_name or ("Session 1" if target_session_id == "default" else None),
)
await new_session.start(startup_command=startup_command)
self._sessions[key] = new_session
# Fire-and-forget DB insert
asyncio.create_task(
self._insert_db_session_row(new_session_id, instance_id, new_session.name)
)
return new_session
async def close_all(self) -> None:
"""Close all active sessions."""
@@ -152,7 +406,7 @@ class TerminalManager:
self._sessions.clear()
for session in sessions:
await session.close()
if self._idle_check_task and not self._idle_check_task.done():
self._idle_check_task.cancel()
+55 -23
View File
@@ -18,18 +18,28 @@ logger = logging.getLogger(__name__)
class TerminalSession:
"""Manages a single terminal session connected to a docker container.
Supports persistent sessions that survive WebSocket disconnections.
Multiple WebSocket connections can attach/detach from the same session.
"""
# Circular buffer size (10KB)
BUFFER_SIZE = 10 * 1024
# Idle timeout in seconds (30 minutes)
IDLE_TIMEOUT = 30 * 60
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None:
# Session number counter per instance_id for auto-naming
_instance_counters: dict[str, int] = {}
def __init__(
self,
session_id: str,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
name: str | None = None,
) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
@@ -38,37 +48,52 @@ class TerminalSession:
self._closed = False
self._master_fd: int | None = None
self._slave_fd: int | None = None
# Circular buffer for output replay
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
self._buffer_size = 0
# WebSocket connections
self._websockets: set[Any] = set()
# Activity tracking
self.last_activity = time.time()
# Terminal size
self._cols = 80
self._rows = 24
# Session metadata
self.name = name or self._generate_name(str(instance_id))
self.status: str = "active"
@classmethod
def _generate_name(cls, instance_id: str) -> str:
"""Generate an auto-incremented session name for the instance."""
count = cls._instance_counters.get(instance_id, 0) + 1
cls._instance_counters[instance_id] = count
return f"Session {count}"
async def start(self, startup_command: str | None = None) -> None:
"""Start the docker exec process with a shell using a PTY."""
# Create a pseudo-terminal on the host
self._master_fd, self._slave_fd = pty.openpty()
# Set the terminal size initially
self._set_terminal_size(self._cols, self._rows)
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
logger.debug(
f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}"
)
# Build the shell command
if startup_command:
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
logger.debug(
f"Using startup command for session {self.session_id}: {startup_command}"
)
else:
shell_cmd = "bash -il"
# Start docker exec with the slave fd as stdin/stdout/stderr
# Using -it because the slave fd IS a TTY
self.process = await asyncio.create_subprocess_exec(
@@ -85,11 +110,11 @@ class TerminalSession:
stdout=self._slave_fd,
stderr=self._slave_fd,
)
# Close slave fd in parent process
os.close(self._slave_fd)
self._slave_fd = None
self.last_activity = time.time()
def _set_terminal_size(self, cols: int, rows: int) -> None:
@@ -99,7 +124,7 @@ class TerminalSession:
return
# TIOCSWINSZ = 0x5414 on Linux
TIOCSWINSZ = 0x5414
size = struct.pack('HHHH', rows, cols, 0, 0)
size = struct.pack("HHHH", rows, cols, 0, 0)
try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
@@ -127,7 +152,7 @@ class TerminalSession:
"""Add data to circular buffer, maintaining size limit."""
self._output_buffer.append(data)
self._buffer_size += len(data)
# Trim if exceeds max size
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
removed = self._output_buffer.popleft()
@@ -152,16 +177,16 @@ class TerminalSession:
if self._closed:
logger.warning("Cannot resize: session is closed")
return
# Only resize if dimensions actually changed
if cols == self._cols and rows == self._rows:
return
self._cols = cols
self._rows = rows
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
self._set_terminal_size(cols, rows)
# Docker exec -it creates its own PTY inside the container,
# so host PTY resize doesn't propagate to the container shell.
# Send SIGWINCH to the docker exec process on the host.
@@ -170,14 +195,19 @@ class TerminalSession:
if self.process and self.process.pid:
try:
os.kill(self.process.pid, signal.SIGWINCH)
logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}")
logger.debug(
f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}"
)
except ProcessLookupError:
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}")
logger.warning(
f"docker exec process {self.process.pid} not found for session {self.session_id}"
)
except Exception as e:
logger.warning(f"Failed to send SIGWINCH: {e}")
async def reset(self) -> None:
"""Reset the session by killing the process and clearing state."""
self.status = "resetting"
await self.close()
self._closed = False
self._output_buffer.clear()
@@ -186,18 +216,20 @@ class TerminalSession:
self.process = None
self._master_fd = None
self._slave_fd = None
self.status = "active"
async def close(self) -> None:
"""Close the session and cleanup."""
if self._closed:
return
self._closed = True
self.status = "closed"
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
pass # noqa: S110
self._master_fd = None
if self.process is not None:
@@ -240,7 +272,7 @@ class TerminalSession:
await ws.send_bytes(data)
except Exception:
dead_sockets.add(ws)
# Clean up dead sockets
for ws in dead_sockets:
self._websockets.discard(ws)
@@ -0,0 +1,67 @@
"""Integration tests for multi-session terminal WebSocket and REST API."""
import pytest
from fastapi.testclient import TestClient
from src.main import app
@pytest.fixture
def client():
return TestClient(app)
class TestTerminalWebSocketMultiSession:
"""Tests for multi-session WebSocket routing."""
def test_specific_session_websocket_route_exists(self, client):
"""The specific session WebSocket route should be registered."""
# We can't easily test WebSocket without auth, but we can verify
# the route exists by checking for a 403 (no auth cookie)
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
# WebSocket endpoint returns 403 when accessed via HTTP GET
assert response.status_code in (403, 404)
def test_default_session_alias_route_exists(self, client):
"""The default session alias route should still exist."""
response = client.get("/ws/tool-instances/test-instance/terminal")
assert response.status_code in (403, 404)
class TestTerminalRestApi:
"""Tests for REST API endpoints."""
def test_list_sessions_requires_auth(self, client):
"""List sessions endpoint requires authentication."""
response = client.get("/instances/test/terminal/sessions")
assert response.status_code == 401
def test_create_session_requires_auth(self, client):
"""Create session endpoint requires authentication."""
response = client.post(
"/instances/test/terminal/sessions",
json={},
)
assert response.status_code == 401
def test_close_session_requires_auth(self, client):
"""Close session endpoint requires authentication."""
response = client.delete("/instances/test/terminal/sessions/test-session")
assert response.status_code == 401
def test_reset_session_requires_auth(self, client):
"""Reset session endpoint requires authentication."""
response = client.post("/instances/test/terminal/sessions/test-session/reset")
assert response.status_code == 401
def test_rename_session_requires_auth(self, client):
"""Rename session endpoint requires authentication."""
response = client.post(
"/instances/test/terminal/sessions/test-session/rename",
json={"name": "New Name"},
)
assert response.status_code == 401
def test_legacy_reset_alias_requires_auth(self, client):
"""Legacy reset endpoint still requires auth."""
response = client.post("/instances/test/terminal/reset")
assert response.status_code == 401
@@ -0,0 +1,203 @@
"""Unit tests for TerminalManager multi-session support."""
import asyncio
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from src.services.terminal_manager import MaxSessionsExceededError, TerminalManager
from src.services.terminal_session import TerminalSession
@pytest.fixture
def manager() -> TerminalManager:
"""Provide a fresh TerminalManager instance for each test."""
tm = TerminalManager()
# Cancel the background idle check to avoid side effects
if tm._idle_check_task and not tm._idle_check_task.done():
tm._idle_check_task.cancel()
return tm
@pytest.fixture
def mock_terminal_session(monkeypatch) -> None:
"""Monkeypatch TerminalSession.start and is_alive for unit tests."""
async def fake_start(self, startup_command=None):
self.last_activity = __import__("time").time()
monkeypatch.setattr(TerminalSession, "start", fake_start)
monkeypatch.setattr(TerminalSession, "is_alive", lambda self: True)
@pytest.fixture
def instance_id() -> uuid.UUID:
return uuid.uuid4()
class FakeWebSocket:
"""Minimal fake WebSocket for testing attach/detach behavior."""
def __init__(self, name: str = "ws") -> None:
self.name = name
self.closed = False
self.close_code: int | None = None
self.close_reason: str | None = None
self._sent: list[bytes] = []
async def close(self, code: int = 1000, reason: str = "") -> None:
self.closed = True
self.close_code = code
self.close_reason = reason
async def send_bytes(self, data: bytes) -> None:
self._sent.append(data)
@pytest.mark.asyncio
async def test_create_session_increases_count(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Creating sessions increments the per-instance count."""
assert len(manager.get_sessions_for_instance(str(instance_id))) == 0
session1 = await manager.create_session(instance_id, "container-1")
assert len(manager.get_sessions_for_instance(str(instance_id))) == 1
assert session1.session_id in [
s.session_id for s in manager.get_sessions_for_instance(str(instance_id))
]
session2 = await manager.create_session(instance_id, "container-1")
assert len(manager.get_sessions_for_instance(str(instance_id))) == 2
# Verify sessions are distinct
assert session1.session_id != session2.session_id
@pytest.mark.asyncio
async def test_create_session_enforces_max_5(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""The 6th session creation raises MaxSessionsExceededError."""
for i in range(5):
await manager.create_session(instance_id, f"container-{i}")
assert len(manager.get_sessions_for_instance(str(instance_id))) == 5
with pytest.raises(MaxSessionsExceededError):
await manager.create_session(instance_id, "container-overflow")
@pytest.mark.asyncio
async def test_get_sessions_for_instance_filters_by_instance(
manager: TerminalManager,
mock_terminal_session,
) -> None:
"""get_sessions_for_instance returns only sessions for the requested instance."""
instance_a = uuid.uuid4()
instance_b = uuid.uuid4()
await manager.create_session(instance_a, "container-a")
await manager.create_session(instance_a, "container-a2")
await manager.create_session(instance_b, "container-b")
assert len(manager.get_sessions_for_instance(str(instance_a))) == 2
assert len(manager.get_sessions_for_instance(str(instance_b))) == 1
@pytest.mark.asyncio
async def test_close_session_removes_from_dict(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""close_session removes the key from _sessions and marks DB closed."""
session = await manager.create_session(instance_id, "container-1")
session_id = session.session_id
assert manager.get_session(str(instance_id), session_id) is not None
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
await manager.close_session(str(instance_id), session_id)
# Give the fire-and-forget task a chance to be scheduled
await asyncio.sleep(0)
assert manager.get_session(str(instance_id), session_id) is None
mock_mark.assert_called_once_with(session_id)
@pytest.mark.asyncio
async def test_attach_websocket_only_closes_same_session(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Attaching to session A must not close WebSockets on session B."""
session_a = await manager.create_session(instance_id, "container-1")
session_b = await manager.create_session(instance_id, "container-1")
ws_a1 = FakeWebSocket("ws-a1")
ws_b1 = FakeWebSocket("ws-b1")
# Manually attach websockets (simulate prior connections)
session_a.attach_websocket(ws_a1)
session_b.attach_websocket(ws_b1)
# Now attach a new websocket to session_a
ws_a2 = FakeWebSocket("ws-a2")
await manager.attach_websocket(session_a, ws_a2)
# ws_a1 should have been closed because it's on the same session
assert ws_a1.closed is True
# ws_b1 should NOT have been closed because it's on a different session
assert ws_b1.closed is False
# ws_a2 should be attached and receive buffer
assert ws_a2 in session_a._websockets
@pytest.mark.asyncio
async def test_default_session_keyed_separately(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Default session uses 'default' session_id and does not collide with named sessions."""
default_session = await manager.get_or_create_session(instance_id, "container-1")
explicit_session = await manager.create_session(instance_id, "container-1")
# Both should exist
assert manager.get_session(str(instance_id), "default") is default_session
assert (
manager.get_session(str(instance_id), explicit_session.session_id)
is explicit_session
)
# They should be different objects
assert default_session.session_id != explicit_session.session_id
@pytest.mark.asyncio
async def test_idle_cleanup_updates_db_status(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Idle cleanup removes sessions from dict and calls DB update."""
session = await manager.create_session(instance_id, "container-1")
session_id = session.session_id
# Make session appear idle (no websockets, old last_activity)
session.last_activity = 0
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
await manager._cleanup_idle_sessions()
assert manager.get_session(str(instance_id), session_id) is None
mock_mark.assert_called_once_with(session_id)
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
{
"version": "v2",
"timestamp": 1779892231625,
"ruleHash": "0a2423849fae7580",
"queries": [
{
"id": "dangerously-set-inner-html",
"name": "Dangerously Set Inner HTML",
"severity": "error",
"language": "tsx",
"message": "dangerouslySetInnerHTML — XSS risk, sanitize user input",
"query": " (jsx_attribute\n (property_identifier) @ATTR\n (#match? @ATTR \"dangerouslySetInnerHTML\"))",
"metavars": [
"ATTR"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/tsx/dangerously-set-inner-html.yml"
},
{
"id": "no-nested-links",
"name": "Nested anchor tags",
"severity": "error",
"language": "tsx",
"message": "Nested <a> tags are invalid HTML and cause unexpected behavior",
"query": " (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @OUTER\n (#eq? @OUTER \"a\"))\n (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @INNER\n (#eq? @INNER \"a\"))))",
"metavars": [
"OUTER",
"INNER"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml"
}
]
}
+477
View File
@@ -0,0 +1,477 @@
{
"version": "v2",
"timestamp": 1779889832502,
"ruleHash": "45ab8be323739a4e",
"queries": [
{
"id": "console-statement",
"name": "Console Statement",
"severity": "warning",
"language": "typescript",
"message": "{{METHOD}} — remove debug statements before committing",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml"
},
{
"id": "debugger-statement",
"name": "Debugger Statement",
"severity": "error",
"language": "typescript",
"message": "Debugger statement — remove before committing",
"query": " (debugger_statement) @DEBUGGER",
"metavars": [
"DEBUGGER"
],
"defect_class": "safety",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/debugger.yml"
},
{
"id": "deep-nesting",
"name": "Deep Nesting",
"severity": "warning",
"language": "typescript",
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
"metavars": [
"IF_NESTED",
"FOR_NESTED",
"WHILE_NESTED",
"TRY_NESTED",
"IF_IN_FOR",
"IF_IN_WHILE"
],
"defect_class": "safety",
"inline_tier": "review",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-nesting.yml"
},
{
"id": "deep-promise-chain",
"name": "Deep Promise Chain (4+ levels)",
"severity": "warning",
"language": "typescript",
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
"metavars": [
"M1",
"M2",
"M3",
"M4"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-promise-chain.yml"
},
{
"id": "default-not-last",
"name": "Default Clauses Should Be Last",
"severity": "error",
"language": "typescript",
"message": "default clause should be the last case",
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
"metavars": [
"DEFAULT",
"AFTER_CASE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/default-not-last.yml"
},
{
"id": "duplicate-function-arg",
"name": "Function Argument Names Should Be Unique",
"severity": "error",
"language": "typescript",
"message": "Duplicate parameter name '{{NAME}}'",
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
"metavars": [
"PARAM1",
"PARAM2"
],
"post_filter": "same_param_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml"
},
{
"id": "empty-switch-case",
"name": "Switch Cases Should Not Be Empty",
"severity": "error",
"language": "typescript",
"message": "Switch case should not be empty",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
"metavars": [
"BLOCK"
],
"post_filter": "is_empty_block",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/empty-switch-case.yml"
},
{
"id": "no-eval",
"name": "Eval Usage",
"severity": "error",
"language": "typescript",
"message": "eval() detected — security risk, never use eval",
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/eval.yml"
},
{
"id": "ts-incomplete-assertion",
"name": "Incomplete Test Assertion",
"severity": "error",
"language": "typescript",
"message": "Incomplete assertion — expect() chain is not called",
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
"metavars": [
"EXPECT",
"EXPR"
],
"post_filter": "incomplete_assertion",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/incomplete-assertion.yml"
},
{
"id": "infinite-loop",
"name": "Loops Should Not Be Infinite",
"severity": "error",
"language": "typescript",
"message": "Loop appears to be infinite with no termination condition",
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
"metavars": [
"BODY"
],
"post_filter": "no_break_or_return_in_body",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/infinite-loop.yml"
},
{
"id": "mixed-async-styles",
"name": "Mixed Async/Await and Promise Chains",
"severity": "warning",
"language": "typescript",
"message": "Mixed async/await + promise chains — use consistent async style",
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
"metavars": [
"BODY"
],
"post_filter": "has_mixed_async",
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/mixed-async-styles.yml"
},
{
"id": "no-console-in-tests",
"name": "Console Statement in Test",
"severity": "warning",
"language": "typescript",
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/no-console-in-tests.yml"
},
{
"id": "self-assignment",
"name": "Variables Should Not Be Self-Assigned",
"severity": "error",
"language": "typescript",
"message": "'{{VAR}}' is assigned to itself",
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
"metavars": [
"VAR",
"SAME"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/self-assignment.yml"
},
{
"id": "sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "typescript",
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
"metavars": [
"SQL_FUNC",
"INTERPOLATION"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/sql-injection.yml"
},
{
"id": "switch-case-termination",
"name": "Switch Cases Should End With Terminating Statement",
"severity": "error",
"language": "typescript",
"message": "Switch case should end with break, return, throw, or continue",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
"metavars": [
"LAST",
"NEXT"
],
"post_filter": "no_terminating_statement",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-case-termination.yml"
},
{
"id": "switch-non-case-labels-ts",
"name": "Switch Should Not Contain Non-Case Labels",
"severity": "error",
"language": "typescript",
"message": "switch statements should not contain non-case labels",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
"metavars": [
"LABEL",
"LABELED"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-non-case-labels.yml"
},
{
"id": "ts-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "typescript",
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
"metavars": [
"MOD",
"NS",
"FN",
"ARGS"
],
"post_filter": "ts_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-command-injection.yml"
},
{
"id": "ts-detached-async-call",
"name": "Detached Async Call",
"severity": "warning",
"language": "typescript",
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
"metavars": [
"FN",
"ARGS"
],
"post_filter": "ts_detached_async_call",
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml"
},
{
"id": "ts-dynamic-require",
"name": "Dynamic Require Injection",
"severity": "error",
"language": "typescript",
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
"metavars": [
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-dynamic-require.yml"
},
{
"id": "ts-hallucinated-react-import",
"name": "Hallucinated React Import",
"severity": "error",
"language": "typescript",
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
"metavars": [
"NAME",
"SRC"
],
"post_filter": "match_captures",
"post_filter_params": {
"SRC": "^['\\\"]react['\\\"]$",
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-hallucinated-react-import.yml"
},
{
"id": "ts-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "typescript",
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
"metavars": [
"OBJ",
"FN",
"ARGS",
"VAR"
],
"post_filter": "ts_insecure_random_source",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-insecure-random.yml"
},
{
"id": "ts-nosql-injection",
"name": "NoSQL Injection",
"severity": "error",
"language": "typescript",
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
"metavars": [
"KEY"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-nosql-injection.yml"
},
{
"id": "ts-open-redirect",
"name": "Open Redirect",
"severity": "error",
"language": "typescript",
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL",
"WIN",
"LOC",
"PROP",
"VALUE"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-open-redirect.yml"
},
{
"id": "ts-react-antipatterns",
"name": "React Anti-Pattern",
"severity": "warning",
"language": "typescript",
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
"metavars": [
"BODY"
],
"defect_class": "logic-error",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-react-antipatterns.yml"
},
{
"id": "ts-ssrf",
"name": "SSRF Risk",
"severity": "error",
"language": "typescript",
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL"
],
"post_filter": "ts_ssrf_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-ssrf.yml"
},
{
"id": "ts-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "typescript",
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
"metavars": [
"FN",
"ALG"
],
"post_filter": "ts_weak_hash_algorithm",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-weak-hash.yml"
},
{
"id": "ts-xss-dom-sink",
"name": "XSS DOM Sink",
"severity": "error",
"language": "typescript",
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
"metavars": [
"PROP",
"VALUE",
"OBJ",
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-xss-dom-sink.yml"
},
{
"id": "unsafe-regex",
"name": "Dynamic Regex Construction",
"severity": "error",
"language": "typescript",
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
"metavars": [
"CTOR",
"INTERPOLATION",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/unsafe-regex.yml"
},
{
"id": "variable-shadowing",
"name": "Variable Shadowing",
"severity": "warning",
"language": "typescript",
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
"metavars": [
"PARAM",
"NAME"
],
"post_filter": "name_matches_param",
"defect_class": "safety",
"inline_tier": "review",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/variable-shadowing.yml"
}
]
}
+77
View File
@@ -0,0 +1,77 @@
import { apiClient } from "./client";
export interface TerminalSession {
id: string;
name: string;
status: string;
has_websockets: boolean;
created_at: string;
last_activity_at: string | null;
}
export interface TerminalSessionListResponse {
sessions: TerminalSession[];
}
export interface TerminalSessionCreateRequest {
name?: string;
}
export interface TerminalSessionCreateResponse {
id: string;
name: string;
status: string;
created_at: string;
}
export async function listTerminalSessions(
instanceId: string,
): Promise<TerminalSession[]> {
const response = await apiClient.get(
`/instances/${instanceId}/terminal/sessions`,
);
return response.data.sessions;
}
export async function createTerminalSession(
instanceId: string,
name?: string,
): Promise<TerminalSessionCreateResponse> {
const response = await apiClient.post(
`/instances/${instanceId}/terminal/sessions`,
{ name },
);
return response.data;
}
export async function closeTerminalSession(
instanceId: string,
sessionId: string,
): Promise<{ status: string; session_id: string }> {
const response = await apiClient.delete(
`/instances/${instanceId}/terminal/sessions/${sessionId}`,
);
return response.data;
}
export async function resetTerminalSession(
instanceId: string,
sessionId: string,
): Promise<{ id: string; name: string; status: string }> {
const response = await apiClient.post(
`/instances/${instanceId}/terminal/sessions/${sessionId}/reset`,
);
return response.data;
}
export async function renameTerminalSession(
instanceId: string,
sessionId: string,
name: string,
): Promise<{ id: string; name: string }> {
const response = await apiClient.post(
`/instances/${instanceId}/terminal/sessions/${sessionId}/rename`,
{ name },
);
return response.data;
}
@@ -0,0 +1,161 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
const mockSessions: TerminalSessionInfo[] = [
{ id: "s1", name: "Session 1", status: "connected" },
{ id: "s2", name: "Session 2", status: "connecting" },
{ id: "s3", name: "Session 3", status: "disconnected" },
];
afterEach(() => {
cleanup();
});
describe("TerminalSessionTabs", () => {
it("renders all tabs", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
expect(screen.getByText("Session 1")).toBeInTheDocument();
expect(screen.getByText("Session 2")).toBeInTheDocument();
expect(screen.getByText("Session 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new session/i })).toBeInTheDocument();
});
it("clicking a tab calls onSelect", () => {
const onSelect = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={onSelect}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
fireEvent.click(screen.getAllByText("Session 2")[0]);
expect(onSelect).toHaveBeenCalledWith("s2");
});
it("close button calls onClose after confirmation", () => {
const onClose = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={onClose}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const closeButton = screen.getByLabelText("Close session Session 1");
// First click shows confirm
fireEvent.click(closeButton);
expect(screen.getByText("Close?")).toBeInTheDocument();
// Click confirm text
fireEvent.click(screen.getByText("Close?"));
expect(onClose).toHaveBeenCalledWith("s1");
});
it("double-click enables rename and Enter commits", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
});
it("double-click enables rename and Escape cancels", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByText("Session 1")).toBeInTheDocument();
});
it("plus button is disabled at 5 sessions", () => {
const fiveSessions: TerminalSessionInfo[] = Array.from({ length: 5 }, (_, i) => ({
id: `s${i + 1}`,
name: `Session ${i + 1}`,
status: "connected",
}));
render(
<TerminalSessionTabs
sessions={fiveSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const newButton = screen.getByRole("button", { name: /new session/i });
expect(newButton).toBeDisabled();
});
it("status dot reflects connection state", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(3);
expect(tabs[0].querySelector(".connected")).toBeInTheDocument();
expect(tabs[1].querySelector(".connecting")).toBeInTheDocument();
expect(tabs[2].querySelector(".disconnected")).toBeInTheDocument();
});
});
@@ -0,0 +1,167 @@
import React, { useState, useRef, useCallback } from "react";
export interface TerminalSessionInfo {
id: string;
name: string;
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export interface TerminalSessionTabsProps {
sessions: TerminalSessionInfo[];
activeSessionId: string;
onSelect: (sessionId: string) => void;
onClose: (sessionId: string) => void;
onCreate: () => void;
onRename: (sessionId: string, newName: string) => void;
isMobile?: boolean;
}
export const TerminalSessionTabs: React.FC<TerminalSessionTabsProps> = ({
sessions,
activeSessionId,
onSelect,
onClose,
onCreate,
onRename,
isMobile = false,
}) => {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const handleDoubleClick = useCallback((session: TerminalSessionInfo) => {
setRenamingId(session.id);
setRenameValue(session.name);
requestAnimationFrame(() => {
renameInputRef.current?.focus();
renameInputRef.current?.select();
});
}, []);
const commitRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
setRenameValue("");
}, [renamingId, renameValue, onRename]);
const cancelRename = useCallback(() => {
setRenamingId(null);
setRenameValue("");
}, []);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
commitRename();
} else if (e.key === "Escape") {
cancelRename();
}
},
[commitRename, cancelRename],
);
const handleCloseClick = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
if (confirmCloseId === sessionId) {
setConfirmCloseId(null);
onClose(sessionId);
} else {
setConfirmCloseId(sessionId);
// Auto-dismiss confirm after 3s
setTimeout(() => {
setConfirmCloseId((prev) => (prev === sessionId ? null : prev));
}, 3000);
}
},
[confirmCloseId, onClose],
);
const isMaxSessions = sessions.length >= 5;
return (
<div
className={`terminal-session-tabs ${isMobile ? "mobile" : ""}`}
role="tablist"
aria-label="Terminal sessions"
>
<div className="terminal-session-tabs-scroll" ref={scrollRef}>
{sessions.map((session) => {
const isActive = session.id === activeSessionId;
const isRenaming = renamingId === session.id;
const isConfirmingClose = confirmCloseId === session.id;
return (
<div
key={session.id}
className={`terminal-session-tab ${isActive ? "active" : ""}`}
role="tab"
aria-selected={isActive}
onClick={() => onSelect(session.id)}
onDoubleClick={() => handleDoubleClick(session)}
title={isRenaming ? "" : `${session.name} (${session.status})`}
>
<span
className={`terminal-session-tab-status ${session.status}`}
aria-hidden="true"
/>
{isRenaming ? (
<input
ref={renameInputRef}
className="terminal-session-tab-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
aria-label="Rename session"
/>
) : (
<span className="terminal-session-tab-name">
{session.name}
</span>
)}
{isConfirmingClose ? (
<button
className="terminal-session-tab-confirm"
onClick={(e) => {
e.stopPropagation();
setConfirmCloseId(null);
onClose(session.id);
}}
type="button"
>
Close?
</button>
) : (
<button
className="terminal-session-tab-close"
onClick={(e) => handleCloseClick(e, session.id)}
type="button"
aria-label={`Close session ${session.name}`}
tabIndex={-1}
>
×
</button>
)}
</div>
);
})}
<button
className="terminal-session-tab new-session"
onClick={onCreate}
disabled={isMaxSessions}
type="button"
aria-label="New session"
title={isMaxSessions ? "Maximum 5 sessions reached" : "New session"}
>
+
</button>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useState } from "react";
import {
listTerminalSessions,
createTerminalSession,
closeTerminalSession,
resetTerminalSession,
renameTerminalSession,
type TerminalSession,
} from "../api/terminal";
export interface UseTerminalSessionsResult {
sessions: TerminalSession[];
activeSessionId: string | null;
setActiveSessionId: (id: string) => void;
createSession: (name?: string) => Promise<TerminalSession | null>;
closeSession: (sessionId: string) => Promise<void>;
renameSession: (sessionId: string, name: string) => Promise<void>;
resetSession: (sessionId: string) => Promise<void>;
loading: boolean;
error: string | null;
}
export function useTerminalSessions(
instanceId: string,
): UseTerminalSessionsResult {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sess = await listTerminalSessions(instanceId);
setSessions(sess);
if (sess.length > 0 && !activeSessionId) {
setActiveSessionId(sess[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load sessions");
} finally {
setLoading(false);
}
}, [instanceId, activeSessionId]);
const createSession = useCallback(
async (name?: string) => {
setError(null);
try {
const newSession = await createTerminalSession(instanceId, name);
const session: TerminalSession = {
id: newSession.id,
name: newSession.name,
status: newSession.status,
has_websockets: false,
created_at: newSession.created_at,
last_activity_at: null,
};
setSessions((prev) => [...prev, session]);
setActiveSessionId(session.id);
return session;
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to create session";
setError(msg);
return null;
}
},
[instanceId],
);
const closeSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await closeTerminalSession(instanceId, sessionId);
setSessions((prev) => {
const filtered = prev.filter((s) => s.id !== sessionId);
if (activeSessionId === sessionId && filtered.length > 0) {
setActiveSessionId(filtered[0].id);
} else if (filtered.length === 0) {
setActiveSessionId(null);
}
return filtered;
});
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to close session",
);
}
},
[instanceId, activeSessionId],
);
const renameSession = useCallback(
async (sessionId: string, name: string) => {
setError(null);
try {
await renameTerminalSession(instanceId, sessionId, name);
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, name } : s)),
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to rename session",
);
}
},
[instanceId],
);
const resetSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await resetTerminalSession(instanceId, sessionId);
// Refetch to get updated session info
await loadSessions();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to reset session",
);
}
},
[instanceId, loadSessions],
);
// Initial load
useEffect(() => {
void loadSessions();
}, [loadSessions]);
return {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
};
}
+309 -42
View File
@@ -1,50 +1,317 @@
import React from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal";
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
import { TerminalComponent, type TerminalRef } from "../components/terminal";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "../components/terminal-session-tabs";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useAutoHide } from "../hooks/use-auto-hide";
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const { instanceId } = useParams<{
instanceId: string;
}>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(instanceId ?? "");
if (isMobile) {
return (
<MobileTerminalWrapper
instanceId={instanceId}
onBack={() => navigate(-1)}
onClose={() => navigate(-1)}
/>
);
}
// Auto-create default session if none exist
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
return (
<section className="terminal-page">
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
</div>
<TerminalComponent
instanceId={instanceId}
onClose={() => navigate(-1)}
isMobile={false}
/>
</section>
);
// Ensure refs map is kept in sync with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
// Clean up refs for closed sessions
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
}
}
}, [sessions]);
// Fit active terminal when switching tabs
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
// Small delay to allow display:block to apply
const timer = setTimeout(() => {
ref.current?.fit();
}, 50);
return () => clearTimeout(timer);
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) {
setActiveSessionId(sessions[idx - 1].id);
}
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1) {
setActiveSessionId(sessions[idx + 1].id);
}
}
break;
case "r":
e.preventDefault();
if (activeSessionId) {
void resetSession(activeSessionId);
}
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
default:
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Exit fullscreen on Escape
useEffect(() => {
if (!isFullscreen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [isFullscreen]);
const handleSelect = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
},
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => {
await closeSession(sessionId);
},
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const sessionInfos = SESSIONS_TO_INFO(sessions);
if (isMobile) {
return (
<section
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
>
<div
className={`terminal-page-header mobile-header ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={() => headerAutoHide.show()}
>
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
>
{isFullscreen ? "Exit" : "Fullscreen"}
</button>
</div>
<div
className={`mobile-tabs-container ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={() => headerAutoHide.show()}
>
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
</div>
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions.map((session) => (
<div
key={session.id}
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
style={{
display: session.id === activeSessionId ? "flex" : "none",
}}
>
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
}
return (
<section className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}>
{!isFullscreen && (
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
title="Toggle fullscreen (Alt+Shift+F)"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</div>
)}
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions.map((session) => (
<div
key={session.id}
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
style={{
display: session.id === activeSessionId ? "flex" : "none",
}}
>
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={false}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
};
+2627 -2331
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
name: multi-session-terminal-ux
status: exploring
started_at: 2026-05-28
@@ -0,0 +1,74 @@
# Apply Report: PR 1 Database + Backend Core for Multi-Session Terminal UX
## Summary
Implemented the database schema, Alembic migration, TerminalManager multi-session core, and TerminalSession name/status tracking for the multi-session terminal UX feature. All changes are backward-compatible with the existing single-session `/terminal` WebSocket endpoint.
### Key Changes
1. **Database Schema** Added `terminal_sessions` table with `UUIDPrimaryKeyMixin` + `TimestampMixin`, storing `instance_id`, `name`, `status`, `last_activity_at`, and `closed_at`.
2. **Alembic Migration** Created migration `2026_05_28_add_terminal_sessions` (down-revision from `20260527_160017_add_pi_agent`).
3. **TerminalSession** Added `name` (auto-generated as "Session N"), `status` field (`active`/`resetting`/`closed`), and updated `reset()`/`close()` to set status appropriately.
4. **TerminalManager** Migrated `_sessions` dict from `dict[str, TerminalSession]` to `dict[tuple[str, str], TerminalSession]`. Added `create_session()`, `get_session()`, `get_sessions_for_instance()`, `close_session()`, and updated `reset_session()` to accept an optional `session_id`. Preserved `get_or_create_session()` for backward compatibility (uses `"default"` session_id). Idle cleanup now operates on composite keys and fires DB status updates asynchronously.
5. **Tests** Created 7 unit tests covering session creation, max-5 enforcement, filtering, close/removal, WebSocket isolation, default session keying, and idle cleanup DB updates.
## Files Created
- `apps/api/src/models/terminal_session.py`
- `apps/api/alembic/versions/2026_05_28_add_terminal_sessions_table.py`
- `apps/api/tests/services/test_terminal_manager_multi.py`
## Files Modified
- `apps/api/src/models/__init__.py` Imported `TerminalSessionModel`
- `apps/api/src/main.py` Imported `TerminalSessionModel` for Alembic model discovery
- `apps/api/src/services/terminal_manager.py` Full refactor to composite-key session management with DB fire-and-forget helpers
- `apps/api/src/services/terminal_session.py` Added `name`, `status`, `_instance_counters`, and status transitions
## Test Results
### New Tests (7/7 passed)
```
$ cd apps/api && python -m pytest tests/services/test_terminal_manager_multi.py -v
tests/services/test_terminal_manager_multi.py::test_create_session_increases_count PASSED
tests/services/test_terminal_manager_multi.py::test_create_session_enforces_max_5 PASSED
tests/services/test_terminal_manager_multi.py::test_get_sessions_for_instance_filters_by_instance PASSED
tests/services/test_terminal_manager_multi.py::test_close_session_removes_from_dict PASSED
tests/services/test_terminal_manager_multi.py::test_attach_websocket_only_closes_same_session PASSED
tests/services/test_terminal_manager_multi.py::test_default_session_keyed_separately PASSED
tests/services/test_terminal_manager_multi.py::test_idle_cleanup_updates_db_status PASSED
======================== 7 passed, 4 warnings in 0.11s =========================
```
### Full Suite (no regressions)
```
$ cd apps/api && python -m pytest tests/ -q
51 failed, 174 passed, 6 warnings in 15.96s
```
- **Baseline failures**: 51 (pre-existing, unchanged by this PR)
- **New passes**: +7 (from `test_terminal_manager_multi.py`)
- **No new failures introduced**
## Deviations from Design
1. **Duplicate `created_at` column** The design spec and its Alembic snippet listed `created_at` twice (once explicitly, once from `TimestampMixin`). I removed the explicit `created_at` from the model and migration, relying on `TimestampMixin` which provides `server_default=func.now()`.
2. **DB write implementation** The design showed DB writes inside `TerminalManager` but didn't specify the exact async pattern. I implemented them as `asyncio.create_task`-wrapped coroutines using `SessionLocal()` so they are non-blocking. Unit tests mock `_mark_closed_in_db` and `_insert_db_session_row` to verify calls without needing a live DB.
3. **`get_or_create_session` auto-name** The design said default session should count toward the 5-session limit. The current implementation does count it, but `get_or_create_session` creates the default session outside the `create_session` path (to preserve backward compat). Future REST endpoints can enforce the limit at the API layer before calling either path.
## Blockers / Risks
- **Global singleton test isolation** `TerminalManager` is still a global singleton (`terminal_manager = TerminalManager()`). The unit tests create fresh instances via the `manager` fixture, but integration tests that import the global may need care to reset state between tests.
- **DB fire-and-forget in tests** The aiosqlite background thread emits `RuntimeError: Event loop is closed` warnings when the test event loop tears down before the fire-and-forget DB task completes. This is harmless in tests but worth monitoring.
- **Migration head** The migration chains from `20260527_160017_add_pi_agent`. If a new migration lands on `dev` before this PR merges, the `down_revision` must be updated.
## Next Recommended Action
1. **Task 5 (WebSocket endpoint + REST API)** Implement the new `/ws/tool-instances/{instance_id}/terminal/{session_id}` WebSocket route and the REST endpoints (`GET/POST/DELETE .../terminal/sessions`) in `apps/api/src/api/terminal.py`. Extract the shared auth/validation/I/O loop into `_handle_terminal_websocket()` as specified in the design.
2. **Run migration in a staging environment** Verify `alembic upgrade head` applies cleanly and `downgrade` reverses without data loss.
3. **Integration tests for WebSocket multi-session** Create `apps/api/tests/api/test_terminal_ws_multi.py` to validate concurrent session isolation and the default-session alias.
@@ -0,0 +1,40 @@
# PR 3: Frontend Multi-Session Terminal UI
## Summary
Implemented the frontend UI for multi-session terminal support: tabbed session management, fullscreen mode, keyboard shortcuts, and mobile integration.
## Files Created
- `apps/web/src/components/terminal-session-tabs.tsx` — Tab bar component with rename, close, status dots, overflow scroll
- `apps/web/src/components/terminal-session-tabs.test.tsx` — 7 passing component tests
## Files Modified
- `apps/web/src/components/terminal.tsx` — Added `sessionId` prop, `TerminalRef` with `fit()`, `forwardRef` wrapper
- `apps/web/src/pages/terminal.tsx` — Multi-session orchestration with tabs, fullscreen, keyboard shortcuts
- `apps/web/src/hooks/use-terminal-sessions.ts` — Hook for session CRUD + state management
- `apps/web/src/api/terminal.ts` — API client for terminal session endpoints
- `apps/web/src/styles.css` — Terminal tab styles, fullscreen mode, mobile responsive
- `apps/api/src/services/terminal_manager.py` — Added lookup by internal session_id fallback
## Acceptance Criteria
- [x] TerminalComponent accepts optional sessionId prop
- [x] WS URL includes sessionId when provided
- [x] TerminalSessionTabs renders sessions with status dots
- [x] Double-click to rename, click × to close (with confirm)
- [x] New session (+) button, disabled at 5 sessions
- [x] Tab switching updates active terminal, calls fit()
- [x] Fullscreen toggle (Alt+Shift+F), exit via Esc
- [x] Keyboard shortcuts: Alt+Shift+N (new), W (close), ←/→ (navigate), R (reset)
- [x] Auto-creates default session if none exist
- [x] Closing last session auto-creates new default
- [x] Mobile: tabs in compact strip, same keyboard shortcuts
- [x] No browser shortcuts overridden (uses Alt+Shift, not Ctrl+Shift)
## Quality Gates
- TypeScript typecheck: ✅ clean
- Frontend tests: ✅ 7/7 terminal-session-tabs tests passing
- Backend tests: ✅ 182 passed, 51 pre-existing failures (no regressions)
- Lint: ✅ 0 errors
## Blockers / Deviations
- MobileTerminalWrapper was not fully integrated with session tabs due to complexity. Mobile path uses inline tab rendering instead.
- This is acceptable for MVP; full mobile integration can be refined in follow-up.
@@ -0,0 +1,867 @@
# SDD Design: Multi-Session Terminal UX
## Architecture Overview
The multi-session terminal extends the existing persistent-session foundation to support up to 5 concurrent terminal sessions per tool instance. The architecture uses a **hybrid storage model**: active PTY processes and WebSocket routing live in-memory (performance-critical path), while session metadata (name, status, timestamps) persists in a new `terminal_sessions` database table.
### High-Level Flow
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ TerminalSession │ │ TerminalSession │ │ TerminalSession │ ... │
│ │ Tabs (Desktop) │ │ Tabs (Mobile) │ │ FullscreenMgr │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │ │
│ ┌────────▼──────────────────────▼──────────────────────▼─────────┐ │
│ │ TerminalSessionManager │ │
│ │ (React state: sessions[], activeSessionId) │ │
│ └────────┬──────────────────────┬──────────────────────┬─────────┘ │
│ │ │ │ │
│ ┌────────▼─────────┐ ┌────────▼─────────┐ ┌────────▼─────────┐ │
│ │ TerminalComponent│ │ TerminalComponent│ │ TerminalComponent│ ... │
│ │ (xterm.js + WS) │ │ (xterm.js + WS) │ │ (xterm.js + WS) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
└───────────┼─────────────────────┼─────────────────────┼────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Backend │
│ ┌──────────────────┐ ┌──────────────────┐ ┌────────────┐ │
│ │ /terminal │ │ /terminal/{sid} │ │ REST /ses- │ │
│ │ (default alias) │ │ (specific sess) │ │ sions │ │
│ └────────┬─────────┘ └────────┬─────────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌────────▼──────────────────────▼────────────────────▼─────┐ │
│ │ TerminalManager │ │
│ │ dict[(instance_id, session_id)] → TerminalSession │ │
│ └────────┬──────────────────────┬──────────────────────────┘ │
│ │ │ │
│ ┌────────▼─────────┐ ┌────────▼─────────┐ │
│ │ TerminalSession │ │ TerminalSession │ ... │
│ │ (PTY + docker │ │ (PTY + docker │ │
│ │ exec process) │ │ exec process) │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌────────▼──────────────────────▼───────────────────────────┐│
│ │ TerminalSessionModel (DB) ││
│ │ instance_id | name | status | created_at | closed_at ││
│ └───────────────────────────────────────────────────────────┘│
└───────────────────────────────────────────────────────────────┘
```
### Key Principles
- **One WebSocket per session**: Each `TerminalComponent` opens its own WebSocket to its specific `session_id`. Inactive sessions keep their WebSocket open to preserve scrollback and real-time output.
- **Max 5 sessions per instance**: Enforced in `TerminalManager.create_session()` and validated in the REST endpoint.
- **Default session alias**: `/ws/tool-instances/{instance_id}/terminal` maps to the single legacy session (or the first/only active session) for backward compatibility.
- **Tab-only UI**: No split panes for MVP. Sessions are presented as tabs on desktop and as a scrollable tab strip integrated into the mobile header area.
---
## Backend Design
### 1. TerminalManager Changes
**File**: `apps/api/src/services/terminal_manager.py`
#### Session Key Change
```python
# BEFORE
self._sessions: dict[str, TerminalSession] = {} # keyed by instance_id
# AFTER
self._sessions: dict[tuple[str, str], TerminalSession] = {} # keyed by (instance_id, session_id)
```
#### New / Modified Methods
| Method | Signature | Behavior |
|--------|-----------|----------|
| `create_session` | `(instance_id, container_id, startup_command=None, name=None) → TerminalSession` | Creates a new `TerminalSession`, starts it, stores under `(instance_id, session_id)`, and inserts a `TerminalSessionModel` DB row. Enforces max 5 sessions. |
| `get_or_create_session` | *(preserved)* | **Backward-compat only.** Returns existing default session or creates one with `session_id="default"`. Called by the legacy `/terminal` WebSocket endpoint. |
| `get_session` | `(instance_id, session_id) → TerminalSession \| None` | Lookup by composite key. |
| `get_sessions_for_instance` | `(instance_id) → list[TerminalSession]` | Returns all in-memory sessions for an instance. |
| `close_session` | `(instance_id, session_id) → None` | Kills the PTY process, removes from `_sessions`, updates DB row `status=closed`, `closed_at=now()`. |
| `reset_session` | *(modified)* | Now accepts an optional `session_id`. If omitted, resets the default session. |
| `attach_websocket` | *(preserved)* | **Critical fix**: The "close existing WebSockets" logic must only close sockets **within the same `(instance_id, session_id)`**. Previously it closed all sockets for the instance. |
#### Default Session Behavior
- The first time a client hits `/ws/.../terminal` (no `session_id`), `TerminalManager` checks if a "default" session exists under key `(instance_id, "default")`.
- If none exists, it creates one (same as `get_or_create_session`).
- The default session counts toward the 5-session limit.
#### Idle Cleanup
```python
async def _cleanup_idle_sessions(self) -> None:
idle_keys = []
for (instance_id, session_id), session in list(self._sessions.items()):
if session.is_idle():
idle_keys.append((instance_id, session_id))
for key in idle_keys:
session = self._sessions.pop(key, None)
if session:
await session.close()
# Update DB status
await self._mark_closed_in_db(key[1])
```
### 2. TerminalSession Changes
**File**: `apps/api/src/services/terminal_session.py`
#### New Fields
```python
class TerminalSession:
# ... existing fields ...
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str,
startup_command: str | None = None, name: str | None = None) -> None:
# ... existing init ...
self.name = name or f"Session {self._next_session_number(instance_id)}"
self.status: str = "active" # active, resetting, closed
```
The `name` field is runtime-only in `TerminalSession`. Renames update the DB via REST, then the frontend uses the new name on next mount or via a lightweight WS status broadcast (optional optimization).
#### Status Tracking
- `active`: Normal operation.
- `resetting`: Transient during `reset()` — cleared after new process starts.
- `closed`: Set after `close()` is called.
### 3. WebSocket Endpoint Changes
**File**: `apps/api/src/api/terminal.py`
#### New Route (Specific Session)
```python
@router.websocket("/ws/tool-instances/{instance_id}/terminal/{session_id}")
async def terminal_websocket_specific(
websocket: WebSocket,
instance_id: str,
session_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
...
```
#### Backward-Compatible Route (Default Session)
```python
@router.websocket("/ws/tool-instances/{instance_id}/terminal")
async def terminal_websocket_default(
websocket: WebSocket,
instance_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
# Identical auth/validation logic
# Calls terminal_manager.get_or_create_session(...) # uses "default" session_id
# Rest of the loop is identical to specific-session endpoint
...
```
#### Refactoring
Both endpoints share the same auth/validation and I/O loop logic. Extract a common coroutine:
```python
async def _handle_terminal_websocket(
websocket: WebSocket,
instance_id: str,
session_id: str | None, # None means default
db_session: AsyncSession,
) -> None:
# Shared: auth, instance lookup, tool_type fetch, session fetch/create,
# attach_websocket, read/write/heartbeat loops, detach_websocket
```
#### Control Messages (Unchanged)
The WebSocket control message protocol is unchanged:
- `{"type": "resize", "cols": 80, "rows": 24}`
- `{"type": "reset"}` — resets the **current** session only
### 4. Database Schema
**File**: `apps/api/src/models/terminal_session.py` (new)
```python
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "terminal_sessions"
instance_id: Mapped[uuid.UUID] = mapped_column(
UUID(),
ForeignKey("tool_instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="active",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
last_activity_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
```
#### Rationale
- `instance_id` is indexed because lookups by instance are frequent (listing sessions, cleanup).
- `name` is nullable; auto-generated names are stored here so they survive page reloads.
- `status` tracks `active` vs `closed`. The `TerminalManager` updates `last_activity_at` whenever a WebSocket attaches/detaches or I/O occurs.
- On API restart, in-memory sessions are lost, but `terminal_sessions` rows remain as metadata history. A future enhancement could resurrect sessions, but that is out of scope.
### 5. Alembic Migration
**File**: `apps/api/src/alembic/versions/XXXX_add_terminal_sessions_table.py`
```python
"""Add terminal_sessions table."""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "<generated>"
down_revision = "<previous>"
def upgrade() -> None:
op.create_table(
"terminal_sessions",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("instance_id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), # TimestampMixin
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), # TimestampMixin
sa.ForeignKeyConstraint(["instance_id"], ["tool_instances.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_terminal_sessions_instance_id"), "terminal_sessions", ["instance_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_terminal_sessions_instance_id"), table_name="terminal_sessions")
op.drop_table("terminal_sessions")
```
### 6. REST API Additions
**File**: `apps/api/src/api/terminal.py` (same file as WebSocket endpoint)
All new endpoints follow the existing URL pattern: `/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/sessions`.
#### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `.../instances/{instance_id}/terminal/sessions` | List sessions for an instance. Returns metadata from DB + live `has_websockets` flag by querying `TerminalManager`. |
| `POST` | `.../instances/{instance_id}/terminal/sessions` | Create a new session. Optional body: `{ "name": "Custom Name" }`. Returns `{ session_id, name, status, created_at }`. Enforces max 5. |
| `DELETE` | `.../instances/{instance_id}/terminal/sessions/{session_id}` | Close a specific session. Kills PTY, updates DB. Returns `{ status: "closed" }`. |
| `POST` | `.../instances/{instance_id}/terminal/sessions/{session_id}/reset` | Reset a specific session (kill + recreate). Returns `{ session_id, name, status }`. |
| `POST` | `.../instances/{instance_id}/terminal/sessions/{session_id}/rename` | Rename a session. Body: `{ "name": "New Name" }`. Updates DB; name reflected on next session list fetch. |
#### Existing Endpoint Preservation
| Method | Path | Behavior |
|--------|------|----------|
| `POST` | `.../instances/{instance_id}/terminal/reset` | **Preserved as alias.** Resets the default session (same as `POST .../sessions/default/reset`). |
#### Response Schema (List Sessions)
```json
{
"sessions": [
{
"id": "uuid",
"name": "Session 1",
"status": "active",
"has_websockets": true,
"created_at": "2026-05-28T10:00:00Z",
"last_activity_at": "2026-05-28T10:05:00Z"
}
]
}
```
---
## Frontend Design
### 1. Session Tabs Component (`TerminalSessionTabs`)
**File**: `apps/web/src/components/terminal-session-tabs.tsx`
#### Props
```typescript
interface TerminalSessionTabsProps {
sessions: TerminalSessionInfo[];
activeSessionId: string;
onSelect: (sessionId: string) => void;
onClose: (sessionId: string) => void;
onCreate: () => void;
onRename: (sessionId: string, newName: string) => void;
isMobile?: boolean;
}
interface TerminalSessionInfo {
id: string;
name: string;
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
```
#### Desktop Behavior
- Horizontal tab strip positioned **above** the terminal container.
- Each tab shows: session name, status dot (colored), close button (×) visible on hover/active.
- Overflow: horizontal scroll with subtle fade indicator.
- **New session button (+)**: Fixed at the right end of the tab strip. Disabled when 5 sessions exist.
- **Double-click to rename**: Inline `<input>` replaces tab text. `Enter` to confirm, `Escape` to cancel. Blur confirms.
- **Close confirmation**: For sessions with an active process and WebSocket, show a lightweight inline confirm tooltip (not a full modal) to avoid friction.
#### Mobile Behavior
- Tab strip is integrated into the existing auto-hide chrome.
- `MobileTerminalHeader` gains a `sessionTabs` render prop or child area below the title row.
- Tabs are compact (icon + truncated name + ×). Horizontal swipe scrolls.
- New session (+) is the rightmost item.
- The tab strip shares the auto-hide behavior with the header (tapping the terminal toggles visibility).
### 2. Modified `TerminalPage`
**File**: `apps/web/src/pages/terminal.tsx`
#### State Management
```typescript
interface TerminalPageState {
sessions: TerminalSessionInfo[];
activeSessionId: string | null;
isFullscreen: boolean;
isLoading: boolean;
}
```
#### Session Lifecycle
1. **Mount**: `useEffect` calls `GET .../terminal/sessions`. If no sessions exist, auto-creates one via `POST`.
2. **Active session**: Only one tab is visually active. **All `TerminalComponent` instances remain mounted** but inactive ones use CSS `display: none` to preserve xterm.js scrollback and WebSocket connections.
3. **Switch tabs**: Updates `activeSessionId`. The newly active tab's `TerminalComponent` triggers `fitAddon.fit()` via a ref callback after becoming visible (using a `useEffect` on visibility).
#### Render Structure
```tsx
<section className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}>
{!isFullscreen && (
<div className="terminal-page-header">...</div>
)}
<TerminalSessionTabs
sessions={sessions}
activeSessionId={activeSessionId}
onSelect={setActiveSessionId}
onClose={handleCloseSession}
onCreate={handleCreateSession}
onRename={handleRenameSession}
/>
<div className="terminal-sessions-container">
{sessions.map((s) => (
<div
key={s.id}
className={s.id === activeSessionId ? "active" : "hidden"}
>
<TerminalComponent
instanceId={instanceId}
sessionId={s.id} // NEW PROP
onClose={() => handleCloseSession(s.id)}
isMobile={isMobile}
// ... other props
/>
</div>
))}
</div>
</section>
```
### 3. Modified `TerminalComponent`
**File**: `apps/web/src/components/terminal.tsx`
#### New Props
```typescript
interface TerminalProps {
instanceId: string;
sessionId?: string; // NEW: omitted → uses default session (backward compat)
// ... existing props
}
```
#### WebSocket URL
```typescript
const wsPath = sessionId
? `/ws/tool-instances/${instanceId}/terminal/${sessionId}`
: `/ws/tool-instances/${instanceId}/terminal`;
```
#### Reset Semantics Update
The component's reset button now sends `{"type": "reset"}` to its own session. The `SessionRef` loop in the backend handles resetting that specific session. After reset, the backend sends `{"type": "status", "status": "connected"}` with the new session object, and the frontend clears the terminal.
#### Fullscreen Awareness
When `TerminalPage` enters fullscreen, it passes `isFullscreen` down (via context or prop drilling). `TerminalComponent` adjusts its container height to `100vh` (minus tab strip if visible in fullscreen).
### 4. Mobile Integration
**File**: `apps/web/src/components/mobile-terminal-wrapper.tsx`
#### Changes
- Accepts `sessions`, `activeSessionId`, and tab callbacks as props from `TerminalPage`.
- Renders `TerminalSessionTabs` between `MobileTerminalHeader` and the terminal content area.
- The tab strip auto-hides along with the header (`useAutoHide`).
- `MobileTerminalHeader` title is updated to show `activeSession.name` instead of generic "Terminal".
- Fullscreen on mobile: hides the header, tab strip, and special-keys strip. A tap in the bottom-right corner (or swipe from edge) reveals the tab strip temporarily.
### 5. Fullscreen Mode
**Trigger**: UI button (maximize icon in header) or `Ctrl+Shift+F`.
#### Desktop Fullscreen
- `TerminalPage` adds `.fullscreen` class.
- Header and page chrome are hidden (`display: none`).
- Tab strip remains visible as a minimal overlay (semi-transparent, auto-hides after 3s of inactivity, reappears on mouse move).
- Terminal container fills viewport.
- Exit: `Esc` key or click exit-fullscreen button.
#### Mobile Fullscreen
- Same as desktop but also hides `SpecialKeysStrip` and `SpecialKeysPanel`.
- A small floating handle at the bottom center reveals the tab strip and special keys on tap.
### 6. Keyboard Shortcuts
**Constraint**: Do not override browser defaults. All shortcuts use combinations that are either unassigned or safe in major browsers.
| Shortcut | Action | Browser Conflict? |
|----------|--------|-------------------|
| `Ctrl+Shift+F` | Toggle fullscreen | None major |
| `Alt+Shift+N` | New session | None major |
| `Alt+Shift+W` | Close current session | None major |
| `Alt+Shift+←` / `Alt+Shift+→` | Previous / next session | None major |
| `Alt+Shift+R` | Reset current session | None major |
All actions are also accessible via UI buttons. Shortcuts are registered in `TerminalPage` via a `useEffect` on `keydown` with `event.preventDefault()` only for the specific combos above.
### 7. Session State Management
**File**: `apps/web/src/hooks/use-terminal-sessions.ts` (new hook)
```typescript
export function useTerminalSessions(instanceId: string) {
const [sessions, setSessions] = useState<TerminalSessionInfo[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const createSession = useCallback(async (name?: string) => { ... }, [instanceId]);
const closeSession = useCallback(async (sessionId: string) => { ... }, [instanceId]);
const renameSession = useCallback(async (sessionId: string, name: string) => { ... }, [instanceId]);
const resetSession = useCallback(async (sessionId: string) => { ... }, [instanceId]);
// Initial load
useEffect(() => {
loadSessions().then((sess) => {
if (sess.length === 0) {
createSession().then((s) => setActiveSessionId(s.id));
} else {
setSessions(sess);
setActiveSessionId(sess[0].id);
}
});
}, [instanceId]);
return { sessions, activeSessionId, setActiveSessionId, createSession, closeSession, renameSession, resetSession };
}
```
---
## Data Flow
### 1. Create New Session
```
User clicks [+] tab
Frontend: POST /instances/{id}/terminal/sessions { name?: "Session 3" }
Backend:
1. Auth + validate instance running
2. Check session count < 5
3. TerminalManager.create_session()
- Generates UUID session_id
- Starts docker exec PTY
- Inserts TerminalSessionModel row
4. Returns { session_id, name, status, created_at }
Frontend:
1. Append session to sessions[]
2. setActiveSessionId(newId)
3. React renders new <TerminalComponent> with sessionId prop
4. Component opens WS to /terminal/{session_id}
5. Backend attaches WS, replays buffer
```
### 2. Switch Between Sessions
```
User clicks tab "Session 2"
Frontend: setActiveSessionId("session-2-uuid")
React re-renders:
- Session 1 container → className="hidden" (display: none)
- Session 2 container → className="active" (display: block)
Session 2 useEffect (on visibility change):
- Calls fitAddon.fit()
- Sends resize message over its existing WS
(Backend: no operation needed. Both WS connections remain open.)
```
### 3. Close Session
```
User clicks [×] on "Session 2"
Frontend: confirm() or inline tooltip
Frontend: DELETE /instances/{id}/terminal/sessions/{session_id}
Backend:
1. Auth
2. TerminalManager.close_session(instance_id, session_id)
- Kills docker exec process
- Removes from _sessions dict
- Updates DB: status=closed, closed_at=now()
3. Returns { status: "closed" }
Frontend:
1. Remove session from sessions[]
2. Unmount <TerminalComponent> (WS closes with code 1000)
3. If closed session was active, setActiveSessionId to another session (or create one if none left)
```
### 4. Reconnect to Existing Session
```
User reloads page
Frontend: GET /instances/{id}/terminal/sessions
Backend: Returns all DB rows with status != "closed"
Frontend: Populate sessions[]. For each session, render <TerminalComponent>.
Each TerminalComponent opens its WS:
WS URL: /ws/tool-instances/{id}/terminal/{session_id}
Backend:
1. Auth
2. TerminalManager.get_session(instance_id, session_id)
- If found in-memory: attach_websocket, replay buffer
- If not found in-memory (API restarted): WS closes with code 4004 "Session not found"
(Frontend handles by showing "Session expired" with option to reset/recreate.)
```
---
## Contracts
### WebSocket Protocol
#### Connection URLs
| URL | Purpose |
|-----|---------|
| `/ws/tool-instances/{instance_id}/terminal` | Default session (backward compatible). Creates/attaches to the single legacy session. |
| `/ws/tool-instances/{instance_id}/terminal/{session_id}` | Specific session. Attaches to an existing session or fails if not found. |
#### Client → Server Messages
| Type | Payload | Purpose |
|------|---------|---------|
| `resize` | `{ cols: number, rows: number }` | Resize PTY |
| `reset` | `{}` | Kill and restart the **current** session's shell |
| `pong` | `{}` | Heartbeat response |
#### Server → Client Messages
| Type | Payload | Purpose |
|------|---------|---------|
| (binary) | `bytes` | PTY output |
| `status` | `{ status: "connected" \| "resetting" }` | Lifecycle status |
| `ping` | `{}` | Heartbeat |
### REST API Contract
#### `GET /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions`
**Response 200:**
```json
{
"sessions": [
{
"id": "uuid",
"name": "Session 1",
"status": "active",
"has_websockets": true,
"created_at": "2026-05-28T10:00:00Z",
"last_activity_at": "2026-05-28T10:05:00Z"
}
]
}
```
#### `POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions`
**Request body:**
```json
{ "name": "Optional Custom Name" }
```
**Response 201:**
```json
{
"id": "uuid",
"name": "Session 2",
"status": "active",
"created_at": "2026-05-28T10:00:00Z"
}
```
**Response 409:** (max sessions reached)
```json
{ "detail": "Maximum of 5 terminal sessions reached for this instance" }
```
#### `DELETE /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}`
**Response 200:**
```json
{ "status": "closed", "session_id": "uuid" }
```
#### `POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}/reset`
**Response 200:**
```json
{
"id": "uuid",
"name": "Session 1",
"status": "active"
}
```
#### `POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}/rename`
**Request body:**
```json
{ "name": "New Name" }
```
**Response 200:**
```json
{ "id": "uuid", "name": "New Name" }
```
---
## Testing Strategy
### Unit Tests
**Backend**: `apps/api/tests/services/test_terminal_manager.py`
| Test | Scenario |
|------|----------|
| `test_create_session_increases_count` | Creating sessions increments the per-instance count |
| `test_create_session_enforces_max_5` | 6th creation raises `MaxSessionsExceededError` |
| `test_get_sessions_for_instance` | Returns only sessions for the requested instance |
| `test_close_session_removes_from_dict` | `close_session` removes key from `_sessions` |
| `test_attach_websocket_only_closes_same_session` | Attaching to session A does not close websockets on session B |
| `test_default_session_keyed_separately` | Default session uses `"default"` session_id and does not collide with named sessions |
| `test_idle_cleanup_updates_db` | Idle cleanup calls DB update with `status=closed` |
**Frontend**: `apps/web/src/components/terminal-session-tabs.test.tsx`
| Test | Scenario |
|------|----------|
| `test_renders_all_tabs` | Renders one tab per session |
| `test_click_tab_selects_session` | Clicking a tab calls `onSelect` with correct ID |
| `test_close_button_calls_onClose` | Clicking × calls `onClose` |
| `test_double_click_enables_rename` | Double-click shows input; Enter commits |
| `test_plus_disabled_at_max_sessions` | `+` button is disabled when 5 sessions exist |
### Integration Tests
**Backend**: `apps/api/tests/api/test_terminal_ws.py`
| Test | Scenario |
|------|----------|
| `test_specific_session_websocket` | Connect to `/terminal/{session_id}`, verify output |
| `test_default_session_alias` | Connect to `/terminal`, verify it creates/uses default session |
| `test_concurrent_sessions_isolated` | Two WS connections to different session_ids receive independent output |
| `test_reset_control_message_scoped` | `{"type":"reset"}` only resets the current session |
| `test_list_sessions_returns_live_and_db` | `GET /sessions` reflects both in-memory state and DB rows |
**Frontend**: `apps/web/src/pages/terminal.test.tsx` (or E2E)
| Test | Scenario |
|------|----------|
| `test_create_session_adds_tab` | Clicking + creates a new tab and switches to it |
| `test_switch_tab_preserves_scrollback` | Switching back to a previous tab shows prior output |
| `test_close_last_session_creates_default` | Closing the final session auto-creates a new default session |
| `test_fullscreen_toggle` | `Ctrl+Shift+F` toggles fullscreen class |
---
## Rollout Plan
### Phase 1: Database (Zero-Downtime)
1. Run Alembic migration to create `terminal_sessions` table.
2. No code reads from or writes to this table yet. Existing sessions remain purely in-memory.
3. **Rollback**: Alembic downgrade removes table (no data loss risk since table is empty).
### Phase 2: Backend API (Backward Compatible)
1. Deploy updated `TerminalManager` with composite key `_sessions`.
2. Deploy updated `TerminalSession` with `name` support.
3. Deploy new WebSocket route `/terminal/{session_id}` and preserve `/terminal` alias.
4. Deploy new REST endpoints (`GET/POST/DELETE .../sessions`).
5. Update DB writes on session lifecycle (create, close, activity update).
6. **Rollback**: Revert code. Old `/terminal` endpoint continues to work. New `/terminal/{session_id}` returns 404, but no clients call it yet.
### Phase 3: Frontend (Feature Flag Optional)
1. Deploy new components (`TerminalSessionTabs`, `useTerminalSessions`).
2. Update `TerminalPage` and `MobileTerminalWrapper`.
3. Update `TerminalComponent` to accept optional `sessionId` prop.
4. If a feature flag is used, enable multi-session UI for beta users first.
5. **Rollback**: Revert frontend. Users see the old single-session UI. Backend `/terminal` alias continues to serve them.
### Phase 4: Deprecation & Cleanup (Follow-Up Task)
1. Monitor usage of the legacy `/terminal` WebSocket endpoint and `POST .../terminal/reset` REST endpoint.
2. After 2-4 weeks of stable multi-session usage:
- Mark legacy endpoints as deprecated in OpenAPI docs.
- Update frontend to always use `/terminal/{session_id}` (never rely on default alias).
3. In a future release, remove the default alias if desired (not required for correctness).
### Backward Compatibility Strategy
| Layer | Compat Mechanism |
|-------|-----------------|
| WebSocket | `/terminal` remains default-session alias forever (or until explicit deprecation). Old clients continue to work. |
| REST API | Existing `POST .../terminal/reset` preserved as alias. No breaking changes to response shape. |
| Frontend | `sessionId` prop on `TerminalComponent` is optional. Omitting it uses the default session path. |
| DB | New table is additive only. No changes to `tool_instances` schema. |
---
## Files to Create / Modify
### New Files
| File | Description |
|------|-------------|
| `apps/api/src/models/terminal_session.py` | SQLAlchemy `TerminalSessionModel` |
| `apps/api/src/alembic/versions/XXXX_add_terminal_sessions_table.py` | Alembic migration |
| `apps/web/src/components/terminal-session-tabs.tsx` | Tab bar UI (desktop + mobile) |
| `apps/web/src/hooks/use-terminal-sessions.ts` | Session CRUD + state hook |
| `apps/web/src/components/terminal-session-tabs.test.tsx` | Unit tests |
| `apps/api/tests/services/test_terminal_manager_multi.py` | TerminalManager multi-session tests |
| `apps/api/tests/api/test_terminal_ws_multi.py` | WS integration tests |
### Modified Files
| File | Changes |
|------|---------|
| `apps/api/src/services/terminal_manager.py` | Composite key dict, new CRUD methods, max session limit, DB integration |
| `apps/api/src/services/terminal_session.py` | Add `name` field, status tracking |
| `apps/api/src/api/terminal.py` | New WS route, REST endpoints, shared handler coroutine |
| `apps/api/src/main.py` | Import new model (if needed for Alembic autogenerate) |
| `apps/web/src/components/terminal.tsx` | Accept `sessionId` prop, use it in WS URL |
| `apps/web/src/pages/terminal.tsx` | Multi-session orchestration, tabs, fullscreen |
| `apps/web/src/components/mobile-terminal-wrapper.tsx` | Integrate tabs, pass session state |
| `apps/web/src/components/mobile-terminal-header.tsx` | Show active session name |
| `apps/web/src/api/sessions.ts` (or new `terminal.ts`) | REST client functions for session CRUD |
---
## Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Resource exhaustion from 5× docker exec per instance | Medium | High | Max 5 enforced. Idle timeout (30 min) still applies per session. |
| Mobile UX degraded by tab bar + special keys strip | Medium | Medium | Auto-hide shared between tabs and header. Minimal tab design. |
| Concurrent WS policy closes wrong session's sockets | Medium | High | Unit test explicitly: attach to session A must not affect session B's websockets. |
| DB writes on hot path (activity tracking) | Low | Medium | `last_activity_at` updates are non-blocking fire-and-forget asyncio tasks. No await on commit. |
| Frontend performance with 5 mounted xterm.js instances | Low | Medium | Max 5 sessions. Inactive terminals are `display: none` (not unmounted). GPU acceleration in xterm.js handles this well. |
| Default session alias ambiguity | Low | Low | Document that `/terminal` maps to `"default"` session. Future deprecation can migrate default to explicit ID. |
@@ -0,0 +1,256 @@
# SDD Explore: Multi-Session Terminal UX
## Executive Summary
The codebase has a well-built persistent terminal foundation from the `persistent-terminal-sessions` change. `TerminalManager` currently tracks exactly one `TerminalSession` per `instance_id` in an in-memory dict. `TerminalSession` already supports WebSocket attach/detach, circular output buffer replay, idle timeout, and process lifecycle management.
Implementing multi-session terminal support is a **moderate-complexity, medium-risk** change. The core backend refactor is straightforward: change the session tracking key from `instance_id` to `(instance_id, session_id)` and update the WebSocket endpoint to accept a `session_id`. The frontend work is more involved: designing a tabbed session UI that works on both desktop and mobile, handling session creation/switching/closing, and integrating with the existing `MobileTerminalWrapper`.
No database schema change is **strictly required** for an MVP—sessions can remain purely in-memory with the same idle-timeout cleanup. However, adding a `terminal_sessions` table would provide cross-API-restart persistence, session auditability, and a foundation for future features like session history or named sessions.
## Current Architecture (as explored)
### Backend
- **`TerminalManager`** (`apps/api/src/services/terminal_manager.py`):
- `self._sessions: dict[str, TerminalSession]` keyed by `instance_id` string.
- `get_or_create_session(instance_id, container_id, startup_command)` — returns the single existing session or creates a new one.
- `attach_websocket(session, websocket)` — detaches any *existing* WebSocket connections on that session (closes them with code 4000) before attaching the new one. This enforces single-active-client per session.
- `reset_session(instance_id, container_id, ...)` — kills the existing session and creates a new one.
- Idle check loop every 60s; sessions with no WebSockets attached for 30 minutes are cleaned up.
- **`TerminalSession`** (`apps/api/src/services/terminal_session.py`):
- Already has a `session_id: str` field (UUID) but it is not used as a lookup key.
- Manages one `docker exec` PTY process per session.
- Circular buffer (10KB) for output replay.
- Tracks `self._websockets: set[Any]` for attached connections.
- **`api/terminal.py`** (`apps/api/src/api/terminal.py`):
- WebSocket endpoint: `/ws/tool-instances/{instance_id}/terminal`
- Authenticates user, verifies instance ownership/running state, then calls `terminal_manager.get_or_create_session()`.
- Supports JSON control messages: `resize`, `reset`.
- POST endpoint: `/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset` — resets the single session.
- **Database**:
- No `terminal_sessions` table exists. Terminal sessions are purely in-memory.
- `ToolInstance` model (`apps/api/src/models/tool_instance.py`) has no terminal-related fields.
### Frontend
- **`TerminalComponent`** (`apps/web/src/components/terminal.tsx`):
- Single xterm.js terminal per component.
- One WebSocket connection to `/ws/tool-instances/{instance_id}/terminal`.
- Handles reconnect with exponential backoff (max 3 attempts).
- Font size persisted globally in `localStorage` under key `terminal-font-size`.
- Copy/paste buttons on mobile only.
- Status indicator: connecting, connected, disconnected, error, resetting.
- **`TerminalPage`** (`apps/web/src/pages/terminal.tsx`):
- Desktop: renders one `TerminalComponent` inside a page shell.
- Mobile: renders `MobileTerminalWrapper` which composes `MobileTerminalHeader`, `TerminalComponent`, `SpecialKeysStrip`, and `SpecialKeysPanel`.
- **`MobileTerminalWrapper`** (`apps/web/src/components/mobile-terminal-wrapper.tsx`):
- Already handles auto-hide header, virtual keyboard height, special keys, and mobile viewport detection.
- Manages terminal ref callbacks (`sendData`, `connectionStatus`, `focusInput`, `changeFontSize`).
### Prior Art
- **`persistent-terminal-sessions`** (fully implemented):
- Sessions survive WebSocket disconnections.
- Buffer replay on reconnect.
- Idle timeout cleanup.
- Reset functionality.
- **`mobile-terminal-ux`** (mostly implemented):
- Mobile fullscreen terminal with collapsible chrome.
- Special keys toolbar.
- Dynamic viewport handling for virtual keyboard.
## Architecture Options for Multi-Session
### Option A: In-Memory Multi-Session (MVP)
- Change `TerminalManager._sessions` to `dict[tuple[str, str], TerminalSession]` keyed by `(instance_id, session_id)`.
- Add `create_session(instance_id, container_id, ...)` that always creates a new session.
- Keep `get_or_create_session()` for backward compatibility (returns the "default" or only session).
- Add `get_sessions_for_instance(instance_id) -> list[TerminalSession]`.
- Add `close_session(instance_id, session_id)` to kill a specific session.
- **Tradeoffs**: Simplest, no DB migration, survives existing patterns. Loses sessions on API restart.
### Option B: Database-Backed Session Metadata
- Create `terminal_sessions` table:
```sql
id UUID PRIMARY KEY,
instance_id UUID FK(tool_instances.id, ondelete=CASCADE),
session_name VARCHAR(255),
status VARCHAR(50), -- active, idle, closed
created_at TIMESTAMPTZ,
last_activity_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ
```
- `TerminalManager` still keeps `TerminalSession` objects in memory, but creates/updates DB rows on lifecycle events.
- **Tradeoffs**: Enables cross-restart persistence, session history, named sessions, and auditability. Adds migration and async DB overhead to hot paths.
### Option C: Hybrid (Recommended)
- In-memory active sessions for performance.
- DB table for metadata, created on session start, updated on activity/close.
- On API restart, sessions are gone (no process resurrection), but metadata remains for history.
- **Tradeoffs**: Best of both worlds. Slightly more complex than Option A but much simpler than full persistence.
### Decision Matrix
| Criterion | Option A | Option B | Option C |
|-----------|----------|----------|----------|
| Implementation complexity | Low | Medium | Medium |
| DB migration required | No | Yes | Yes |
| Cross-restart persistence | No | Yes (full) | Metadata only |
| Resource auditability | No | Yes | Yes |
| Performance | Best | Good (cacheable) | Best |
| Recommended for MVP | **Yes** | No | **Preferred** |
## WebSocket Protocol Options
### Option 1: URL Path Segment (Recommended)
```
/ws/tool-instances/{instance_id}/terminal/{session_id}
```
- Clean, RESTful, easy to route in FastAPI.
- Default session can use a reserved ID like `default` or keep `/terminal` as an alias.
- **Tradeoff**: Breaks existing hardcoded URLs; needs backward-compatibility route.
### Option 2: Query Parameter
```
/ws/tool-instances/{instance_id}/terminal?session_id=...
```
- Easier to add without changing route structure.
- Less idiomatic for WebSocket APIs.
- **Tradeoff**: Query params in WebSocket URLs can be inconsistently supported by proxies.
### Option 3: First-Message JSON Payload
- Client connects to `/terminal`, then sends `{"type": "attach", "session_id": "..."}`.
- Server must hold the connection in limbo until the attach message arrives.
- **Tradeoff**: More complex state machine; harder to reject invalid sessions early.
**Recommendation**: Option 1 with a backward-compatible fallback:
- `/ws/tool-instances/{instance_id}/terminal` → attaches to the "default" session (existing behavior).
- `/ws/tool-instances/{instance_id}/terminal/{session_id}` → attaches to the specified session.
## Frontend UX Design Options
### Session Presentation: Tabs vs Panes
| Feature | Tabs | Panes (Split) |
|---------|------|---------------|
| Desktop UX | Good | Excellent (tmux-like) |
| Mobile UX | Good | Poor (too cramped) |
| Implementation | Medium | High |
| Accessibility | Good | Complex |
| Recommendation | **Preferred** | Future enhancement |
**Decision**: Start with tabs. A split-pane layout can be added later as an advanced feature without breaking the tab model.
### Tab Bar Design
- Position: Above the terminal container on desktop; integrated into `MobileTerminalHeader` on mobile.
- Contents:
- Session name (auto-named "Session 1", "Session 2", or custom).
- Status dot (connecting, connected, error).
- Close button (×) on hover/active.
- New tab button (+).
- Overflow: Horizontal scroll on mobile; wrap or scroll on desktop.
### Fullscreen Mode
- **Behavior**: Toggle hides all page chrome (header, sidebar, tab bar can optionally be shown as a minimal overlay).
- **Trigger**: `Ctrl+Shift+F` or UI button.
- **Mobile**: Should integrate with existing mobile fullscreen behavior (already hides AppShell). Fullscreen on mobile could mean hiding the special-keys strip too, with a gesture to reveal.
- **Exit**: `Esc` or UI button.
### Keyboard Shortcuts
| Shortcut | Action | Notes |
|----------|--------|-------|
| `Ctrl+Shift+N` | New session | May conflict with browser "New window" on some platforms. Consider `Ctrl+Shift+T` if not used for "Reopen tab". |
| `Ctrl+Shift+W` | Close current session | Conflicts with browser "Close window". May need `Ctrl+Shift+D` or accept override with `preventDefault()`. |
| `Ctrl+Shift+F` | Toggle fullscreen | Safe, no major browser conflict. |
| `Ctrl+Shift+T` | Toggle tab bar visibility | Conflicts with "Reopen closed tab" in browsers. Consider `Ctrl+Shift+B` or `Ctrl+Shift+~`. |
**Recommendation**: Use `preventDefault()` aggressively and show a shortcuts help modal (e.g., `Ctrl+Shift+/` or `?`).
### Session Naming
- **Auto-name**: "Session 1", "Session 2", etc. based on creation order.
- **Custom name**: Editable by double-clicking the tab. Persisted in DB if Option B/C, or in-memory only for Option A.
- **Default session**: The first session created for an instance can be unnamed or named "Default".
### Reset/Kill Semantics
Current behavior: "Reset Terminal" kills the single session and starts fresh.
With multi-session:
- **Close Session** (× on tab): Kills the `docker exec` process and removes the session.
- **New Session** (+ on tab bar): Creates a new session and switches to it.
- **Reset Session** (in menu): Same as current reset but scoped to the active session.
- **Reset All** (optional, in menu): Kill all sessions for the instance and recreate a default one.
### Font Size Persistence
- Currently global (`localStorage` key `terminal-font-size`).
- With multi-session, users may want different font sizes per session (e.g., larger for presentations, smaller for logs).
- **Options**:
1. Keep global (simplest, no change).
2. Per-session font size (stored in session state or DB).
3. Per-instance font size.
- **Recommendation**: Keep global for MVP. Per-session font size is a nice-to-have that adds complexity.
### Status Per Session
- Each tab shows a status dot.
- Possible statuses: `connecting` (pulsing), `connected` (green), `disconnected` (yellow), `error` (red), `closed` (gray).
- The terminal component already tracks these statuses; they just need to be surfaced at the tab level.
## Database Schema Recommendation (Option C)
```python
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "terminal_sessions"
instance_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("tool_instances.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="active"
)
# Not storing process PID here — that's runtime-only in TerminalManager
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.utcnow
)
last_activity_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
```
**Migration**: New alembic revision adding `terminal_sessions` table.
## Open Questions Needing User/Product Decisions
1. **Max sessions per instance?** Suggest 5 for MVP to prevent resource exhaustion.
2. **Should we persist sessions across API restarts?** Option A = no; Option C = metadata only. Product call.
3. **Tab vs Pane UI?** Strongly recommend tabs for MVP. Panes as future work.
4. **Keyboard shortcuts — override browser defaults?** `Ctrl+Shift+W` closes browser window. We can `preventDefault()` but should warn users.
5. **Should the existing `/terminal` endpoint remain as a default-session alias?** Yes for backward compatibility, but confirm.
6. **Session idle timeout per session or global per instance?** Currently per session. Keep per session.
7. **Should font size be global, per-instance, or per-session?** Recommend global for MVP.
8. **Copy/paste on desktop — any gaps?** Current desktop relies on native xterm.js copy/paste (`Ctrl+C`/`Ctrl+V` with selection). This is standard and sufficient. Mobile already has buttons.
## Risks and Feasibility Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Resource exhaustion from too many docker exec processes | Medium | High | Enforce max sessions per instance (5). Idle timeout already exists. |
| Mobile UX degradation from tab bar clutter | Medium | Medium | Integrate tabs into existing `MobileTerminalHeader` auto-hide. Limit visible tabs, overflow scroll. |
| Backward compat breakage from URL change | Low | Medium | Keep `/terminal` as default-session alias. |
| Concurrent WebSocket policy bugs | Medium | High | Ensure "close existing" only applies within same `(instance_id, session_id)`, not across sessions. |
| Scope creep (panes, detachable windows) | High | Medium | Explicitly exclude split panes and detachable windows from MVP. |
## Feasibility: Green/Yellow/Red
**Yellow-Green**. The backend changes are well-scoped and build on solid existing infrastructure. The frontend tab UI is the largest unknown, especially mobile integration, but the existing `MobileTerminalWrapper` provides a good foundation. No external dependencies needed.
## Recommended Next Step
**Proceed to `design` phase** after resolving these scoping decisions:
1. Choose Option A or C for session storage (recommend Option C).
2. Confirm max sessions limit (recommend 5).
3. Confirm tab-only UI for MVP (no panes).
4. Confirm backward-compatible WebSocket URL strategy.
Then write `design.md` with concrete decisions and `tasks.md` with implementation steps.
@@ -0,0 +1,33 @@
## Why
Currently, each tool instance (e.g., pi-agent, code-server) supports exactly one terminal session. Users who want to run multiple concurrent tasks (e.g., a long-running build in one pane, an editor in another, and a shell for quick commands) must open multiple tool instances or use tmux/screen inside a single session. This is inefficient and confusing.
Additionally, the web terminal lacks basic usability features found in modern terminal emulators: fullscreen mode, detachable panes, session tabs, and keyboard shortcuts for common actions.
## What Changes
- **Backend**: Allow multiple `TerminalSession` objects per `ToolInstance`, each with a unique `session_id`
- **Backend**: Update `TerminalManager` to track and route multiple sessions per instance
- **Backend**: Update terminal WebSocket protocol to include `session_id` in connection URL or message
- **Frontend**: Add session tabs/management UI (create new session, switch between sessions, close sessions)
- **Frontend**: Add fullscreen mode for the terminal
- **Frontend**: Add keyboard shortcuts for session management (Ctrl+Shift+N new session, etc.)
- **Frontend**: Session list panel showing active sessions per instance
## Capabilities
### New Capabilities
- `multi-session-terminal`: Multiple independent terminal sessions per tool instance
- `terminal-fullscreen`: Fullscreen terminal mode
- `terminal-session-management`: Create, switch, rename, and close terminal sessions
### Modified Capabilities
- `tool-terminal`: Extend WebSocket protocol and UI to support multiple sessions per instance
- `terminal-session-lifecycle`: Session creation, naming, and cleanup for multi-session model
## Impact
- Backend: `TerminalManager`, `TerminalSession`, `api/terminal.py`, database schema (session tracking)
- Frontend: `TerminalComponent`, `terminal.tsx`, new `TerminalSessionTabs`, `TerminalSessionManager`
- Protocol: WebSocket message format changes (add session_id field)
- Database: New or extended table to track terminal sessions per instance
@@ -0,0 +1,404 @@
# SDD Tasks: Multi-Session Terminal UX
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~1,4001,600 (new ~900, modified ~600700) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1: DB + Backend Core → PR 2: Backend API + Tests → PR 3: Frontend + Tests |
| Delivery strategy | auto-chain |
| Chain strategy | stacked-to-main |
```text
Decision needed before apply: Yes
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
```
---
## Task Overview
| # | Task | PR | Est. Lines | Dependencies |
|---|------|-----|------------|--------------|
| 1 | Database schema and Alembic migration | 1 | ~80 | None |
| 2 | TerminalManager multi-session core | 1 | ~250 | Task 1 |
| 3 | TerminalSession name and status fields | 1 | ~40 | Task 2 |
| 4 | WebSocket routing and backward-compat alias | 2 | ~200 | Task 2 |
| 5 | REST endpoints for session CRUD | 2 | ~180 | Task 2, 4 |
| 6 | Frontend API client and `useTerminalSessions` hook | 3 | ~180 | Task 5 |
| 7 | `TerminalComponent` `sessionId` support | 3 | ~100 | Task 4, 6 |
| 8 | `TerminalSessionTabs` UI component | 3 | ~220 | Task 6 |
| 9 | `TerminalPage` multi-session orchestration and fullscreen | 3 | ~200 | Task 7, 8 |
| 10 | Mobile terminal integration | 3 | ~100 | Task 8, 9 |
| 11 | Backend integration tests | 2 | ~250 | Task 4, 5 |
| 12 | Frontend component tests | 3 | ~150 | Task 8, 9, 10 |
---
## PR 1: Database + Backend Core
### Task 1: Database Schema and Alembic Migration
**Scope**: Create the `terminal_sessions` metadata table and corresponding Alembic migration.
**Files to create**:
- `apps/api/src/models/terminal_session.py`
- `apps/api/alembic/versions/XXXX_add_terminal_sessions_table.py`
**Files to modify**:
- `apps/api/src/main.py` — import new model so Alembic autogenerate discovers it
**Acceptance Criteria**:
- `TerminalSessionModel` extends `Base`, `UUIDPrimaryKeyMixin`, `TimestampMixin`
- Columns: `instance_id` (UUID, FK `tool_instances.id` ON DELETE CASCADE, indexed), `name` (String 255, nullable), `status` (String 50, default `"active"`), `created_at` (DateTime TZ, non-nullable), `last_activity_at` (DateTime TZ, nullable), `closed_at` (DateTime TZ, nullable)
- Migration is reversible (`downgrade` drops table + index)
- `make migrate` applies successfully in local dev
**Testing (TDD)**:
- RED: Write a migration metadata test asserting the new table exists in `Base.metadata` and has expected columns
- GREEN: Create model and migration
- Run `pytest tests/integration/test_models.py` or equivalent to verify table registration
---
### Task 2: TerminalManager Multi-Session Core
**Scope**: Refactor `TerminalManager` to support up to 5 concurrent sessions per instance using composite keys.
**Files to modify**:
- `apps/api/src/services/terminal_manager.py`
**Acceptance Criteria**:
- `self._sessions` keyed by `(instance_id: str, session_id: str)`
- `create_session(instance_id, container_id, startup_command=None, name=None)`:
- Generates UUID `session_id`
- Enforces max 5 active sessions per instance (raise `MaxSessionsExceededError` / HTTP 409)
- Inserts `TerminalSessionModel` DB row (fire-and-forget async task acceptable)
- Returns `TerminalSession`
- `get_or_create_session(instance_id, container_id, ...)` preserved for backward compatibility; uses `"default"` session_id
- `get_session(instance_id, session_id)` returns session or `None`
- `get_sessions_for_instance(instance_id)` returns list of in-memory sessions
- `close_session(instance_id, session_id)`: kills PTY, removes from `_sessions`, updates DB `status=closed`, `closed_at=now()`
- `reset_session(instance_id, container_id, session_id=None)`: if `session_id` omitted, resets `"default"` session
- `attach_websocket` only closes existing WebSockets **within the same `(instance_id, session_id)`**
- `_cleanup_idle_sessions` uses composite keys and updates DB status on cleanup
- Idle timeout (30 min) and buffer replay behavior preserved
**Testing (TDD)**:
- RED: Create `apps/api/tests/services/test_terminal_manager_multi.py` with tests:
- `test_create_session_increases_count`
- `test_create_session_enforces_max_5`
- `test_get_sessions_for_instance_filters_by_instance`
- `test_close_session_removes_from_dict_and_updates_db`
- `test_attach_websocket_only_closes_same_session`
- `test_default_session_keyed_separately`
- `test_idle_cleanup_updates_db_status`
- GREEN: Implement `TerminalManager` changes
- Run `make test-unit`
---
### Task 3: TerminalSession Name and Status Fields
**Scope**: Add runtime `name` and `status` tracking to `TerminalSession`.
**Files to modify**:
- `apps/api/src/services/terminal_session.py`
**Acceptance Criteria**:
- `__init__` accepts optional `name`; auto-generates `"Session N"` if omitted (N = per-instance counter)
- `self.name` stored as runtime attribute
- `self.status` enum-like string: `"active"`, `"resetting"`, `"closed"`
- `reset()` sets `status="resetting"` during transition, `"active"` after restart
- `close()` sets `status="closed"`
- No breaking changes to existing `TerminalSession` behavior
**Testing (TDD)**:
- RED: Extend `test_terminal_manager_multi.py` or add `test_terminal_session_name_and_status.py` covering auto-naming, status transitions, and reset/close side effects
- GREEN: Implement fields and transitions
- Run `make test-unit`
---
## PR 2: Backend API + Tests
### Task 4: WebSocket Routing and Backward-Compat Alias
**Scope**: Add session-scoped WebSocket route, extract shared handler, preserve legacy alias.
**Files to modify**:
- `apps/api/src/api/terminal.py`
**Files to create**:
- `apps/api/tests/api/test_terminal_ws_multi.py`
**Acceptance Criteria**:
- New route: `@router.websocket("/ws/tool-instances/{instance_id}/terminal/{session_id}")`
- Existing route `@router.websocket("/ws/tool-instances/{instance_id}/terminal")` preserved; calls `get_or_create_session(...)` for `"default"` session
- Extract `async def _handle_terminal_websocket(websocket, instance_id, session_id, db_session)` containing shared auth/validation/I/O loop logic
- Both routes call `_handle_terminal_websocket`
- Auth/validation logic unchanged (cookie-based, ownership check, running status)
- `reset` control message scoped to the current session only (via `SessionRef` update)
- On unknown `session_id`, close WS with code `4004` "Session not found"
**Testing (TDD)**:
- RED: Write `test_terminal_ws_multi.py`:
- `test_specific_session_websocket_connects`
- `test_default_session_alias_creates_default`
- `test_concurrent_sessions_isolated_output`
- `test_reset_control_message_scoped_to_session`
- `test_unknown_session_id_returns_4004`
- GREEN: Implement routes and shared handler
- Run `pytest tests/api/test_terminal_ws_multi.py`
---
### Task 5: REST Endpoints for Session CRUD
**Scope**: Add REST endpoints for listing, creating, closing, resetting, and renaming sessions.
**Files to modify**:
- `apps/api/src/api/terminal.py`
**Acceptance Criteria**:
- `GET /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions`
- Returns `{ sessions: [...] }` with `id`, `name`, `status`, `has_websockets`, `created_at`, `last_activity_at`
- `has_websockets` queried live from `TerminalManager`
- `POST .../terminal/sessions` — body `{ name?: string }`
- Returns `201` with `{ id, name, status, created_at }`
- Returns `409` if max 5 reached
- `DELETE .../terminal/sessions/{sid}` — returns `{ status: "closed", session_id }`
- `POST .../terminal/sessions/{sid}/reset` — returns `{ id, name, status }`
- `POST .../terminal/sessions/{sid}/rename` — body `{ name: string }`, returns `{ id, name }`
- Existing `POST .../terminal/reset` preserved as alias for default session reset
- All endpoints validate auth, ownership, and running instance status
**Testing (TDD)**:
- RED: Add integration tests in `test_terminal_ws_multi.py` or new `test_terminal_rest.py`:
- `test_list_sessions_returns_db_and_live_state`
- `test_create_session_201`
- `test_create_session_409_at_max`
- `test_close_session_200`
- `test_reset_session_200`
- `test_rename_session_200`
- `test_legacy_reset_alias_still_works`
- GREEN: Implement endpoints
- Run `make test-integration`
---
### Task 6: Frontend API Client and `useTerminalSessions` Hook
**Scope**: Add frontend REST client functions and the central session state hook.
**Files to create**:
- `apps/web/src/api/terminal.ts` (new file for terminal-specific API calls)
- `apps/web/src/hooks/use-terminal-sessions.ts`
**Files to modify**:
- `apps/web/src/api/sessions.ts` — optional, or keep terminal API separate
**Acceptance Criteria**:
- API functions: `listTerminalSessions`, `createTerminalSession`, `closeTerminalSession`, `resetTerminalSession`, `renameTerminalSession`
- `useTerminalSessions(instanceId: string)` hook:
- Loads sessions on mount; auto-creates one if list is empty
- Exposes `sessions`, `activeSessionId`, `setActiveSessionId`
- Exposes `createSession`, `closeSession`, `renameSession`, `resetSession` with optimistic UI updates
- Handles 409 errors (max sessions) gracefully
- Refetches after reset/rename to stay in sync
**Testing (TDD)**:
- RED: Write hook unit tests mocking API client:
- `test_loads_sessions_on_mount`
- `test_auto_creates_session_if_empty`
- `test_close_session_removes_from_state`
- `test_create_session_enforces_max_5_error`
- GREEN: Implement hook and API client
- Run `cd apps/web && npm test`
---
## PR 3: Frontend + Tests
### Task 7: `TerminalComponent` `sessionId` Support
**Scope**: Update `TerminalComponent` to accept an optional `sessionId` and route WS accordingly.
**Files to modify**:
- `apps/web/src/components/terminal.tsx`
**Acceptance Criteria**:
- New optional prop `sessionId?: string`
- WS URL constructed as:
- `/ws/tool-instances/{instanceId}/terminal/{sessionId}` if `sessionId` provided
- `/ws/tool-instances/{instanceId}/terminal` if omitted (backward compat)
- Reset button sends `{"type": "reset"}` to the correct session's WS
- Component still supports all existing props and mobile behavior
- `onTerminalReady` callback still works; parent can differentiate sessions by key
**Testing (TDD)**:
- RED: Add/update `terminal.test.tsx` (or similar) to assert WS URL includes `sessionId` when provided
- GREEN: Implement prop and URL logic
- Run `cd apps/web && npm test`
---
### Task 8: `TerminalSessionTabs` UI Component
**Scope**: Build the tab bar for desktop and mobile.
**Files to create**:
- `apps/web/src/components/terminal-session-tabs.tsx`
- `apps/web/src/components/terminal-session-tabs.test.tsx`
**Acceptance Criteria**:
- Props interface: `sessions`, `activeSessionId`, `onSelect`, `onClose`, `onCreate`, `onRename`, `isMobile?`
- Desktop: horizontal tab strip above terminal, overflow scroll with fade indicator
- Mobile: compact tabs integrated into auto-hide chrome, horizontal swipe scroll
- Each tab shows: name, status dot (connecting/connected/disconnected/error), close button (×) on hover/active
- Double-click to rename: inline `<input>`, `Enter` to confirm, `Escape` to cancel, blur confirms
- New session button (+) at right end; disabled when 5 sessions exist
- Close confirmation: lightweight inline confirm tooltip (not modal)
- Accessible: `role="tablist"`, `role="tab"`, keyboard navigation
**Testing (TDD)**:
- RED: Write `terminal-session-tabs.test.tsx`:
- `test_renders_all_tabs`
- `test_click_tab_calls_onSelect`
- `test_close_button_calls_onClose`
- `test_double_click_enables_rename`
- `test_plus_disabled_at_max_sessions`
- `test_status_dot_reflects_connection_state`
- GREEN: Implement component
- Run `cd apps/web && npm test`
---
### Task 9: `TerminalPage` Multi-Session Orchestration, Fullscreen, and Shortcuts
**Scope**: Rewrite `TerminalPage` to manage multiple mounted terminals, fullscreen mode, and keyboard shortcuts.
**Files to modify**:
- `apps/web/src/pages/terminal.tsx`
**Acceptance Criteria**:
- Uses `useTerminalSessions` hook
- Renders `<TerminalSessionTabs />` above terminal area
- Renders one `<TerminalComponent />` per session; inactive sessions hidden via `display: none` (preserves scrollback and WS)
- On tab switch, active terminal calls `fitAddon.fit()` via ref + `useEffect` on visibility
- Fullscreen toggle:
- `Ctrl+Shift+F` toggles `.fullscreen` class
- Desktop: hides page header; tab strip becomes minimal overlay (auto-hides after 3s, reappears on mouse move)
- Mobile: hides header, tab strip, special keys; floating handle reveals chrome
- Exit via `Esc` or UI button
- Keyboard shortcuts (registered in `useEffect` on `keydown`):
- `Alt+Shift+N` — new session
- `Alt+Shift+W` — close current session
- `Alt+Shift+←` / `Alt+Shift+→` — prev/next session
- `Alt+Shift+R` — reset current session
- All use `preventDefault()` only for the exact combo; no browser overrides
- Closing last session auto-creates a new default session
**Testing (TDD)**:
- RED: Add `terminal-page.test.tsx`:
- `test_creates_default_session_on_empty_load`
- `test_switching_tabs_hides_inactive_terminals`
- `test_fullscreen_toggle_adds_class`
- `test_keyboard_shortcut_creates_session`
- `test_close_last_session_auto_creates_default`
- GREEN: Implement page orchestration
- Run `cd apps/web && npm test`
---
### Task 10: Mobile Terminal Integration
**Scope**: Integrate session tabs into mobile terminal wrapper and update header.
**Files to modify**:
- `apps/web/src/components/mobile-terminal-wrapper.tsx`
- `apps/web/src/components/mobile-terminal-header.tsx`
**Acceptance Criteria**:
- `MobileTerminalWrapper` accepts session-related props from `TerminalPage` and passes them to `TerminalSessionTabs`
- `MobileTerminalHeader` displays `activeSession.name` instead of generic `"Terminal"`
- Tab strip shares `useAutoHide` behavior with header (tapping terminal toggles visibility)
- Special keys strip remains functional; no z-index conflicts with tabs
- Fullscreen on mobile correctly hides/shows all chrome layers
**Testing (TDD)**:
- RED: Add/update mobile wrapper tests:
- `test_renders_session_tabs`
- `test_header_shows_session_name`
- `test_auto_hide_applies_to_tabs`
- GREEN: Implement mobile integration
- Run `cd apps/web && npm test`
---
### Task 11: Backend Integration Tests
**Scope**: Complete backend test coverage for multi-session WebSocket and REST behavior.
**Files to create / modify**:
- `apps/api/tests/services/test_terminal_manager_multi.py` (finalize)
- `apps/api/tests/api/test_terminal_ws_multi.py` (finalize)
**Acceptance Criteria**:
- All tests from Tasks 2, 4, 5 pass
- Additional integration tests:
- `test_list_sessions_after_api_restart_shows_db_metadata` (simulates restart by clearing in-memory dict)
- `test_two_websockets_on_same_session_receive_same_output`
- `test_idle_cleanup_per_session_not_global`
- `make test` passes (unit + integration)
**Testing (TDD)**:
- These are the GREEN/TRIANGULATE phases for earlier backend tasks; ensure coverage is comprehensive
---
### Task 12: Frontend Component Tests
**Scope**: Finalize frontend test coverage for tabs, page, and hook.
**Files to create / modify**:
- `apps/web/src/components/terminal-session-tabs.test.tsx` (finalize)
- `apps/web/src/hooks/use-terminal-sessions.test.ts` (new, if not created earlier)
- `apps/web/src/pages/terminal.test.tsx` (new)
**Acceptance Criteria**:
- Tab component tests cover rendering, selection, close, rename, and max-session disable
- Hook tests cover load, create, close, error handling
- Page tests cover session lifecycle, fullscreen, and keyboard shortcuts
- `cd apps/web && npm test` passes
**Testing (TDD)**:
- Finalize RED→GREEN→TRIANGULATE for all frontend tasks
---
## Risks and Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Resource exhaustion (5× docker exec per instance) | Medium | High | Max 5 enforced in `create_session`. Idle timeout (30 min) applies per session. |
| Mobile UX degraded by tab bar + special keys strip | Medium | Medium | Auto-hide shared between tabs and header. Compact tab design. Overflow scroll. |
| Concurrent WS policy closes wrong session's sockets | Medium | High | Explicit unit test: `attach_websocket` must only affect same `(instance_id, session_id)`. |
| DB writes on hot path (activity tracking) | Low | Medium | `last_activity_at` updates are fire-and-forget async tasks; do not block I/O loop. |
| Frontend performance with 5 mounted xterm.js instances | Low | Medium | Max 5 sessions. Inactive terminals use `display: none` (not unmounted). xterm.js GPU acceleration handles this. |
| Default session alias ambiguity | Low | Low | Document that `/terminal` maps to `"default"`. Future deprecation can migrate to explicit IDs. |
| Browser shortcut conflicts | Low | Medium | Use `Alt+Shift+*` instead of `Ctrl+Shift+W/N`. Only `preventDefault()` on exact matching combos. |
---
## Rollback Plan
- **PR 1 rollback**: Alembic downgrade removes `terminal_sessions` table. Old `TerminalManager` code is fully replaced, so reverting PR 1 requires reverting all subsequent PRs.
- **PR 2 rollback**: Revert API changes. Legacy `/terminal` WS route and `POST .../terminal/reset` continue to work; new `/terminal/{session_id}` returns 404 but no clients call it until PR 3 is deployed.
- **PR 3 rollback**: Revert frontend. Users see old single-session UI. Backend `/terminal` alias continues to serve them.
Because PRs are stacked, rolling back PR 2 or PR 1 requires rolling back all dependent PRs above it.
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Fix ghost migration on production server
# Run this INSIDE the api container or on the server
echo "=== Checking for ghost migration file ==="
find /app/alembic/versions -name "*tool_definition*" 2>/dev/null
echo ""
echo "=== Current alembic_version in DB ==="
psql "$DATABASE_URL" -c "SELECT * FROM alembic_version;"
echo ""
echo "=== Checking if tool_types has definition_manifests column ==="
psql "$DATABASE_URL" -c "\d tool_types" | grep -i manifest
echo ""
echo "=== Our known migration heads ==="
grep -r "revision.*=" /app/alembic/versions/*.py | grep "2026_05_28"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Securely update the PostgreSQL password for the headquarter database user.
Usage:
python scripts/update_db_password.py
The script will prompt for the new password (no echo) and update it via the
running hq-postgres Docker container.
After running, remember to update your .env file:
POSTGRES_PASSWORD=<your-new-password>
"""
import getpass
import subprocess
import sys
def main() -> None:
# Prompt for new password securely (no echo to terminal)
new_password = getpass.getpass("Enter new password for 'headquarter' user: ")
if not new_password:
print("Error: password cannot be empty.", file=sys.stderr)
sys.exit(1)
confirm = getpass.getpass("Confirm new password: ")
if new_password != confirm:
print("Error: passwords do not match.", file=sys.stderr)
sys.exit(1)
# Use psql inside the running postgres container to avoid exposing
# the password in host shell history.
sql = f"ALTER ROLE headquarter WITH PASSWORD '{new_password}';"
try:
result = subprocess.run(
[
"docker",
"exec",
"-i",
"hq-postgres",
"psql",
"-U",
"headquarter",
"-d",
"headquarter",
"-c",
sql,
],
capture_output=True,
text=True,
check=True,
)
print(result.stdout.strip())
print("\n✅ Password updated successfully.")
print("\n⚠️ IMPORTANT: Update your .env file:")
print(f" POSTGRES_PASSWORD={new_password}")
print("\n⚠️ Then restart the application containers:")
print(" docker compose up -d")
except subprocess.CalledProcessError as exc:
print(f"Error: {exc.stderr or exc.stdout}", file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print(
"Error: 'docker' command not found. Is Docker installed and running?",
file=sys.stderr,
)
sys.exit(1)
if __name__ == "__main__":
main()