- Add global [[providers]] config with project-level provider_refs
- Add provider management page in web admin with preset support
- Implement card-based /provider add flow: preset selection card
with sponsor presets, API key prompt, and manual "Other" option
- Add inline button support for Telegram-style platforms
- Add remote provider-presets.json with sponsor data
- Add REST API endpoints for global providers and provider refs
- Update project detail providers tab with unified list UI
Made-with: Cursor
Previously done_emoji only fired when stream preview had been updated
in-place at least once, which meant quick responses never triggered
the reaction. Remove the needsDoneReaction() gate so the platform's
AddDoneReaction is always called on EventResult.
Made-with: Cursor
Remove the standalone /agentsid command and fold its output (Agent SID)
into /status. Also add work_dir to the status output so users can see
the current working directory at a glance.
Changes:
- Remove /agentsid command registration, handler, card button, help
text, and all i18n entries (MsgAgentSIDWarning, MsgBuiltinCmdAgentSID)
- Add Agent SID line to /status (both plain-text and card variants),
shown only when a session is active
- Add Work Dir line to /status output (i18n: 5 languages)
- Add MsgStatusAgentSID i18n key
Made-with: Cursor
Previously, /switch overwrote the active session's AgentSessionID,
causing the old agent session to disappear from KnownAgentSessionIDs
and thus from /list output.
Add SwitchToAgentSession() which finds an existing internal session
for the target agent session ID or creates a new one, preserving all
previous session references.
Made-with: Cursor
When quiet mode is enabled (ThinkingMessages=false, ToolMessages=false),
EventThinking and EventToolUse events are suppressed but the stream
preview card is not frozen. This causes multiple Claude text outputs
to accumulate into a single preview card.
Fix: when thinking/tool events arrive in quiet mode and there is
accumulated text, freeze + detachPreview to preserve the current text
as a permanent message. When the preview is already degraded (after
the first freeze), send accumulated text directly as a regular message
instead of silently advancing segmentStart past it.
Co-authored-by: leoliang <leoliang@futunn.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tables were rendered as proportional-width plain text, causing columns
to be misaligned in Telegram clients. Wrap tables in <pre> with
per-column padding so they display correctly in monospace.
Related: openclaw/openclaw#36323
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
H1: Kimi readLoop — move cmd.Wait() before EventResult to prevent
out-of-order EventError after turn completion.
H2: Kimi tests — add skipUnlessKimiAvailable guard so CI without
kimi binary skips gracefully instead of failing.
H3: Claude shellJoinArgs — add comprehensive unit tests for shell
quoting (spaces, single quotes, backslashes, newlines).
M1: cli_path — document that paths with spaces are not supported.
M3: needsDoneReaction — add streaming preview tests covering
UpdateMessage trigger, discard reset, and disabled state.
M5: /agentsid — add security warning about session ID sensitivity
in the command response.
M7: i18n — localize reply_footer_remaining for ZH/ZH-TW/JA/ES;
add MsgAgentSIDWarning translations for all 5 languages.
Made-with: Cursor
* feat(cli): add `cc-connect agent-sid` command to query agent session ID
Reads the agent session ID (e.g. Claude Code / Codex / Gemini CLI session
ID used with --resume) from persisted session files. Auto-detects project
and session key from CC_PROJECT and CC_SESSION_KEY env vars, making it
usable directly inside agent subprocess sessions.
Handles all session file naming variants: plain, hashed, workspace (_ws_),
legacy paths, and .sessions.json. When multiple files contain the same key,
prefers the one with the newest UpdatedAt. Includes nil-safety, file modtime
fallback for zero-UpdatedAt entries, and proper error propagation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(core): add /agentsid chat command to show agent session ID
Registers /agentsid (alias: /agent-sid) as a builtin chat command so
users can query the agent session ID directly from Discord/Telegram/
Feishu etc. without asking the agent to run a Bash command.
- Added to builtinCommands registry, handleCommand switch, and help
card (session group)
- Added to text-based /help output for all 5 languages (EN/ZH/ZH-TW/JA/ES)
- Added i18n description key MsgBuiltinCmdAgentSID with translations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(lint): use strings.TrimPrefix instead of HasPrefix+slice
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the agent's response is delivered via in-place card update (Feishu
streaming preview), the user gets no push notification. This adds a
configurable done_emoji option (e.g. "Done") that adds a reaction after
the "OnIt" reaction is removed, so the user knows the agent finished.
Only activates in quiet mode when the preview card was updated in-place
at least once (via UpdateMessage). Non-quiet mode is unaffected as
intermediate messages already trigger push notifications. Single-round
responses that only use SendPreviewStart are also unaffected, as the
initial send already triggers a push notification.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(core): add optional reply footer metadata
* feat(config): enable reply footer by default
* feat(codex): use runtime footer metadata from app-server
* feat(codex): resolve exec footer metadata at runtime
* feat(core): style reply footer and prefer context left
* feat(codex): use real runtime context for reply footer
* feat(core): use english left footer text
* fix(codex): use current context tokens for footer left
* fix(codex): match cli context left calculation
* test(codex): adapt footer tests to session base url arg
H1: Prevent workspace path traversal via ../
Add resolveLocalDirPath() that validates resolved path stays under
baseDir, expands ~/... via os.UserHomeDir, rejects escaping paths.
M2: Sanitize sender_name in inject_sender header
Replace quotes with single quotes, strip newlines/CR to prevent
prompt injection via display names.
M3: Warn on unset env var placeholders in config
resolveEnvPlaceholders now logs slog.Warn when ${VAR} references
an environment variable that is not set.
M4: Validate WeCom api_base_url
Reject non-http(s) or malformed URLs at New() to prevent credential
exfiltration via misconfigured base URL.
M5: Quote systemd Environment= values
Wrap values in double quotes to handle spaces, #, and special chars
in proxy URLs and paths.
M7: Discord table detection now requires separator row
Only wrap pipe-delimited blocks that contain a markdown separator
row (| --- | --- |), reducing false positives on decorative pipes.
Made-with: Cursor
H1: Prevent double-Close on concurrent cleanupInteractiveState calls
by nil'ing state.agentSession under the lock before closing.
H2: Weixin StartTyping sends typingStatusStop on ctx.Done() and on
repeat-failure exit, preventing stuck "typing" indicator.
M3: Feishu @mention resolution: downgrade PII log to Debug, HTML-escape
display names in at tags, skip ambiguous (duplicate) names.
M4: Narrow segmentStart==0 condition in EventResult to only apply when
tool messages are hidden (!e.display.ToolMessages).
M5: Log warning when cron workspace agent creation fails instead of
silently falling back to global agent.
M7: Discord file download capped at 50 MiB via io.LimitReader.
M8: Weixin getConfig now checks ret/errcode fields and returns error
on non-zero values.
Made-with: Cursor
The BoundAt field in WorkspaceBinding uses time.Time which requires
strict RFC3339 format. When workspace_bindings.json is hand-edited
(e.g. by an AI agent), a missing timezone suffix causes json.Unmarshal
to fail for the entire file, breaking ALL workspace bindings.
Introduce FlexTime, a time.Time wrapper with lenient UnmarshalJSON
that tries multiple time formats and falls back to zero value on
failure instead of returning an error. This ensures a non-critical
metadata field cannot take down the workspace routing.
Previously `/workspace init` only accepted git URLs. Users with existing
local directories had no way to bind them to a channel without cloning.
Now `/workspace init <path>` accepts:
- Absolute paths: `/workspace init /data/code/my-project`
- Relative names: `/workspace init my-project` (resolved under base_dir)
- Home-relative: `/workspace init ~/code/my-project`
The conversational init flow also accepts directory paths — users can
send a directory path when prompted instead of only git URLs.
Adds looksLikeLocalDir() helper, MsgWsInitDirNotFound and
MsgWsInitInvalidTarget i18n keys with translations for all 5 languages,
and 4 new test cases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
event.SessionID can be empty in some cases, causing the
agent_session_id to never be written to disk. When cc-connect
restarts, the session resumes without any agent_session_id and
Claude Code treats it as a brand new conversation, losing all
context.
Fix: use state.agentSession.CurrentSessionID() (interactive mode)
and agentSession.CurrentSessionID() (relay mode) instead, which
always returns the actual session ID held by the agent. Also call
sessions.Save() after SetAgentSessionID() to ensure the change
is flushed to disk.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two race conditions fixed:
1. cleanupInteractiveState timing (main issue):
- Previously: state was deleted from interactiveStates map BEFORE
closeAgentSessionWithTimeout was called synchronously
- Problem: during the blocking close (up to 130s for Stop hooks),
the map was empty but agent session was still shutting down
- Result: concurrent requests saw conflicting state - some got
"No execution in progress" while others saw blocking behavior
- Fix: delete state from map AFTER closeAgentSessionWithTimeout
completes, with re-check to avoid deleting replaced states
2. readLoop defer leak (secondary issue):
- Previously: defer had early return on ctx.Done() that skipped
closing cs.events and cs.done channels
- Problem: if Close() timed out and called cancel(), the defer
returned early without closing channels
- Result: Close() waits forever on cs.done at line 633
- Fix: remove early return from defer, always close channels
Root cause identified in issue #595:
- Session cleanup during /stop in cleanup window created conflicting
replies ("Previous request still processing" vs "No execution in progress")
- MCP servers and Stop hook subprocesses with stdio:inherit could
block scanner.Scan() indefinitely, preventing defer cleanup
Test plan:
- go build ./... passes
- go vet ./... passes
- go test ./... passes
Fixes#595
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Weixin platform now implements core.AudioSender interface, enabling TTS
audio output for WeChat personal chat via ilink API.
- Added ConvertAudioToAMR in core/speech.go for audio conversion
- Implemented SendAudio in platform/weixin/media_outbound.go
- Converts WAV/MP3/TTS output to AMR format (Weixin voice codec)
- Uploads via CDN and sends as voice message type
Fixes#587: TTS audio was generated but silently dropped because
weixin did not implement AudioSender interface.
Test plan:
- go build ./... passes
- go test ./... passes
- Unit tests for SendAudio edge cases (empty audio, invalid context)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When inject_sender is enabled, the agent now receives the sender's
human-readable display name alongside the opaque user ID. This lets
agents distinguish between users by name in shared channel sessions
(e.g. Slack channels where multiple people talk to the same bot).
Header format changes from:
[cc-connect sender_id=U123 platform=slack chat_id=C789]
to:
[cc-connect sender_id=U123 sender_name="Jim Tang" platform=slack chat_id=C789]
The name is quoted to handle spaces. When no display name is available
(e.g. platform didn't resolve it), the header falls back to the
existing format without sender_name.
Changes:
- Add userName param to buildSenderPrompt and thread it through all 3
call sites (direct send, queued message drain, event-loop drain)
- Add userName field to queuedMessage so queued messages preserve the
display name across turns
- Add tests for empty name fallback and names with spaces
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Qwen TTS (DashScope) API rejects empty string values for the
language_type parameter with a 400 InvalidParameter error. The field
is optional — when omitted, the API auto-detects the language.
Previously, QwenTTS.Synthesize always included language_type in the
request body, even when opts.LanguageType was empty (the default from
engine.go's sendTTSReply). This caused all TTS synthesis to fail on
Feishu platform with:
qwen tts API 400: InvalidParameter: Input should be 'Auto',
'Chinese', 'English', ... : input.language_type
Fix: only include language_type in the request when it is explicitly
set (non-empty). When empty, the field is omitted entirely, letting
the API auto-detect the language from the input text.
This aligns the implementation with the TTSSynthesisOpts comment:
LanguageType string // e.g. "Chinese", "English"; empty = auto-detect
High priority:
- PR #420: fix Telegram reply context lost by alias resolution —
resolveAlias now runs on user text before ExtraContent merge
- PR #567: fix message queue race — adoptPendingFromPlaceholder
copies queued messages when placeholder state is replaced
- PR #437: harden /diff command — reject targets starting with '-',
use '--' separator to prevent git option injection
- PR #560: add run_as_env dangerous variable blacklist (LD_PRELOAD,
PATH, HOME, etc.); fix doc claiming PATH is in default allowlist
Medium priority:
- PR #497: ACP permission params log level Info→Debug to prevent
sensitive data leaking; add toolInputByID cache size cap (1000)
- PR #559: observe new JSONL files start from EOF instead of offset 0,
preventing full session history replay to Slack
- PR #510: add idempotent Close() test for claude session
- PR #514: bridge ReconstructReplyCtx already has structured JSON +
backward compat; added decode helper + tests
- PR #440: add instance lock acquire/release unit tests
- PR #569: document ContinueSession behavioral change in code comments
All changes backward-compatible. Tests added for all fixes.
Made-with: Cursor
External Claude CLI sessions in the same work_dir were appearing in /list,
/switch, /delete, and /search results because ListSessions() scans all
.jsonl files in the project directory without filtering.
- Add KnownAgentSessionIDs() to SessionManager to collect tracked agent IDs
- Add filterOwnedSessions() to exclude external CLI sessions from all
session listing operations (9 call sites in engine.go)
- Remove dead --continue code path from claudecode/session.go (engine no
longer passes ContinueSession sentinel)
- Add regression tests for the filtering logic
Closes#541
Co-authored-by: Claude <noreply@anthropic.com>
When a session lock is held and a new message arrives, the queue
mechanism requires an interactiveState entry to exist. Previously,
this entry was only created inside the async goroutine after the
agent process was spawned — a potentially slow operation. Messages
arriving during this startup window were silently dropped.
Fix:
- Create a placeholder interactiveState before launching the async
processor so the queue entry point exists immediately
- Relax queueMessageForBusySession guard to allow queueing when
agentSession is nil (startup phase), while still rejecting when
the agent session was established but has died
Co-authored-by: Claude <noreply@anthropic.com>
normalizeWorkspacePath resolves /var/folders/ to /private/var/folders/
on macOS, but shell pwd returns the original path. The test assertion now
checks both the original and resolved paths.
Co-authored-by: happytonakai <hanzr.nju@outlook.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add run_as_user for per-project OS-user isolation
Implements #496. Adds an optional `run_as_user` field at the project
level that, when set, spawns the project's agent command under a
different Unix user via `sudo -n -iu <user> --`. This provides OS-level
file-system isolation from the supervisor user that runs cc-connect
itself: a misbehaving agent can no longer reach files outside the
target user's permission scope, even via python -c / shell redirection
/ other non-hooked code paths.
## What lands in this PR
### Config (config/)
- new fields `run_as_user *string` and `run_as_env []string` on each
`[[projects]]` entry
- validation: rejects root, rejects invalid usernames, rejects use on
Windows, enforces POSIX username charset to close a sudo argv
injection vector
### Core primitives (core/runas*.go)
POSIX-only (Windows stubs provided for API compatibility).
- `BuildSpawnCommand(ctx, opts, name, args...)` — wraps exec.Cmd with
`sudo -n -iu <user> --preserve-env=<allowlist> -- name args...` when
opts.RunAsUser is set; passthrough otherwise
- `FilterEnvForSpawn(env, opts)` — strips supervisor env to a minimal
allowlist (PATH, LANG, LC_*, TERM) plus any project-level extensions
- `VerifyRunAsUserCheap` — the two cheap sudo probes, suitable for
per-spawn re-verification (catches sudoers edits between startup and
spawn)
- `PreflightRunAsUser` — the three go/no-go startup gates from #496:
(1) passwordless sudo to target works,
(2) target cannot passwordless sudo (if escalation succeeds, captures
`sudo -n -l` output in the error message), and
(3) target can read AND write work_dir root. Plus a non-fatal
descendant scan warning on unreadable/unwritable descendants with
prune-paths, 10s timeout, and a 50-entry cap.
- `RunIsolationProbe` + embedded runas_probe.sh (//go:embed) — the
leak-audit probe codex asked for: spawns a fixed shell script as the
target user and parses its output into a structured IsolationReport.
Reports effective id/groups/umask/pwd, target-home config presence,
cross-user denial attempts for every other configured run_as_user,
and supervisor-denial attempts. Never reads file contents, only
existence and access.
All of these are testable without a real second user via an injectable
`SudoRunner` interface; production uses ExecSudoRunner.
### Failure policy
Per-spec: all checks are hard go/no-go, not warnings.
- Any preflight fatal in any project: cc-connect refuses to start
entirely; no half-spawned processes.
- Any audit-level `CROSS_LEAKED` (target reads another project user's
secrets) or `SUPERVISOR_LEAKED` (target reads the supervisor's
secrets) or `WORKDIR_WRITABLE=no`: cc-connect refuses to start.
- Descendant-scan findings are warnings (inaccessibility inside a
project tree is sometimes intentional).
### Startup wiring (cmd/cc-connect/)
- `runRunAsUserStartupChecks` runs preflight + isolation probe for
every project with run_as_user set, in parallel, BEFORE any agent
or engine is constructed. Logs every outcome via slog. On any fatal,
prints the full list and `os.Exit(1)`.
- injects project-level run_as_user / run_as_env into the agent's
options map before calling `core.CreateAgent`, so agents can opt in
without new top-level plumbing.
### Doctor subcommand (cmd/cc-connect/doctor_runas.go)
New `cc-connect doctor user-isolation [--project NAME] [--out PATH]`
subcommand. Runs preflight + isolation probe for one or all
`run_as_user` projects, writes a JSON report per project, and prints a
human-readable summary. Default output is
`~/.cc-connect/audits/<timestamp>-<project>.json`.
Also supports `--print-script` to dump the embedded probe source for
inspection (and for running shellcheck against it).
### Claude Code wiring (agent/claudecode/)
- `Agent` struct gains `runAsUser` and `runAsEnv` fields
- `New()` parses them from the opts map and skips the
`exec.LookPath("claude")` check when run_as_user is set (the target
user's PATH is what matters, not the supervisor's)
- `newClaudeSession` takes `core.SpawnOptions`, runs the per-spawn
cheap re-check immediately before building the command, uses
`core.BuildSpawnCommand` instead of `exec.CommandContext`, and
filters env via `core.FilterEnvForSpawn`
### Docs
- new "Running agents as a different Unix user" section in
`docs/usage.md` with setup steps, sudoers example, ACL example,
environment-propagation migration checklist (PGSSL*, ~/.claude/
settings.json, ~/.pgpass, ~/keys/, ~/.ssh/, MCP binaries, shell
profile, language toolchains), opt-out instructions, and
failure-mode debug guide
- README section with a short configuration example and a pointer to
the full docs
- `config.example.toml` commented example with setup recipe
### Tests
- config: parse run_as_user/run_as_env, reject root, reject invalid
chars, Windows rejection, username validator valid/invalid cases
- core: BuildSpawnCommand legacy vs. sudo form, FilterEnvForSpawn
filtering, VerifyRunAsUserCheap success/no-sudo/escalation-possible/
empty-user, PreflightRunAsUser all-pass/no-sudo/escalation-fatal/
workdir-fatal/descendant-warnings/descendant-cap, probe parser
clean/cross-leak-fatal/supervisor-leak-fatal/workdir-not-writable-
fatal, shell quoting, other-users filtering, embedded probe script
sanity
- agent/claudecode: New() parses run_as_user/run_as_env, run_as_user
skips LookPath
- cmd/cc-connect: default audit dir suffix, printHumanReport doesn't
panic on populated report
Uses stubbed SudoRunners throughout — no tests require a real second
Unix user or real sudo binary.
## Scope
Only Claude Code is wired up in this PR. Each non-claudecode agent has
its own session/spawn lifecycle (codex re-spawns per turn, cursor has
its own state model, etc.) so the sweep needs per-agent reasoning and
is deferred to a follow-up. The core primitives land here so each
future agent migration is a small, self-contained PR.
Follow-up issue tracks: codex, cursor, gemini, iflow, opencode, qoder,
acp, pi.
Refs: #496
Credit: issue framing via user + codex adversarial review
(go/no-go gates, leak-audit probe, env propagation docs,
sudo -iu rationale pinning)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(run_as_user): simplify after code review
Addresses findings from a parallel code-reuse / code-quality / efficiency
review of #498. Behavior unchanged; tests still pass.
Highlights:
- cache VerifyRunAsUserCheap results for 30s keyed by runAsUser, so
rapid-fire messages from the same Slack user don't pay the ~100ms
per-spawn sudo tax. Cache evicts on failure so the next spawn
re-verifies fresh. Adds ResetVerifyCache for tests + operators.
- doctor user-isolation now fans out across projects in parallel
(mirroring runas_startup.go) instead of iterating serially. Per-project
output is buffered and printed in order so the report stays grouped.
- collapse Agent.runAsUser + Agent.runAsEnv into a single
core.SpawnOptions field — eliminates the reassemble-at-call-site
pattern.
- fold core/runas_username.go into core/runas.go (one 17-line file →
one helper near its only caller).
- IsolationReport.RawOutput is now only populated when the audit had a
fatal — clean reports are smaller on disk.
- introduce typed string constants for the probe wire format
(tagBegin / tagWorkDirReadable / tagCrossLeaked / ...) so the parser
and runas_probe.sh share a discoverable contract.
- itoa() in tests → strconv.Itoa
- contains() helper in tests → slices.Contains
- mass comment trim across runas.go / runas_check.go / runas_audit.go:
delete WHAT-restates-name comments (~70 lines) while keeping the
load-bearing WHY blocks (sudo -n -iu rationale, env propagation
reasoning, find expression format note, scan timeout justification).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runas): drop PATH from default env allowlist
Preserving PATH across the sudo boundary defeats the whole point of
-i: it overrides the target user's login-profile PATH with the
supervisor's, and leaks supervisor work directories (e.g. the
cc-connect source tree on the supervisor's PATH) into the isolated
session.
sudo -i now rebuilds PATH from /etc/profile + the target user's
~/.profile. Binaries the isolated agent needs (like the claude CLI)
should be installed system-wide (e.g. /usr/local/bin symlinks) or put
on the target user's own shell profile.
Caught by Q6/Q8 daemon-spawn verification in data-worklog Phase 0a:
partseeker-coder sessions were running with leigh's PATH intact.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(engine): propagate run_as_user to per-workspace agents
In multi-workspace mode, getOrCreateWorkspaceAgent constructs a fresh
opts map for each per-workspace agent instead of inheriting the
project-level opts that main.go decorated at startup. As a result,
run_as_user (and run_as_env) silently dropped out of the per-workspace
spawn path — the project-level config said partseeker-coder but every
spawn actually ran as the supervisor.
Fix: expose GetRunAsUser/GetRunAsEnv accessors on *claudecode.Agent
and copy them across in getOrCreateWorkspaceAgent via interface
assertion, mirroring the existing GetModel/GetMode pattern. Two
regression tests cover the propagation and the no-propagation case
so multi-workspace deployments that don't use run_as_user are
unaffected.
Caught by Q6/Q8 daemon-spawn verification in data-worklog Phase 0a.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add OAuth credential sharing guidance for run_as_user setups
OAuth access tokens expire after a few hours and only get refreshed by
active Claude CLI sessions. When the target user has no active session
between cc-connect spawns, its token expires and auth fails with 401.
Document the symlink + ACL pattern: target user's credentials.json
symlinks to the supervisor's copy so both share one auto-refreshed token.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: graceful session shutdown — close stdin before signals
The old Close() called context.Cancel() which sends SIGKILL immediately
via Go's exec.CommandContext, giving Claude Code zero time to run Stop
hooks. This broke claude-mem session summaries (120s timeout) and any
other cleanup that depends on a clean exit.
New three-phase shutdown:
1. Close stdin (EOF) — Claude Code exits cleanly, running Stop hooks
2. SIGTERM after 30s grace period if still alive
3. SIGKILL after 5s more as last resort
Also increases engine-level close timeout from 10s to 45s to accommodate
the longer graceful window.
Tested: all agent/claudecode and core tests pass. Verified Claude Code
exits cleanly on stdin EOF (exit code 0).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: 120s graceful close + hold message during session transition
Increases the default graceful stop timeout from 30s to 120s to match
claude-mem's Stop hook timeout. The wait ends as soon as the process
exits — typical shutdowns take seconds, not the full 120s.
When a user messages during an idle-reset that triggers a graceful
close, they now see "Wrapping up your previous session (usually a few
seconds, up to 2 minutes). Your new session will start automatically."
instead of a silent delay. The hold message is sent before the blocking
close, and the new session starts immediately when the old one finishes.
Engine-level close timeout raised to 130s (120s grace + 5s SIGTERM +
5s buffer).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Leigh Stillard <leigh@stillard.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(observe): add --observe CLI flag and engine config
Add observe fields to the Engine struct and a SetObserveConfig setter
to enable terminal session observation in a later task. Define --observe
and --observe-channel CLI flags (wiring deferred).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): add ObserverTarget interface, implement for Slack
Define ObserverTarget optional interface in core/ for platforms that can
receive terminal observation messages. Implement SendObservation on Slack
platform using PostMessageContext with link unfurl disabled.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): implement JSONL session file watcher and parser
Adds sessionObserver to core/observer.go which tails ~/.claude/projects/{projectKey}/*.jsonl
files, parses user/assistant events, filters out sdk-cli sessions (cc-connect's own),
and forwards new messages to ObserverTarget. Includes full test coverage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): wire observer into engine startup and shutdown
Add findObserverTarget to locate the first ObserverTarget platform,
startObserver to launch the JSONL watcher goroutine when configured,
wired into Engine.Start() and cancelled in Engine.Stop().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): wire --observe and --observe-channel flags in CLI
Removes the TODO suppressor and connects the --observe/--observe-channel
CLI flags to engine.SetObserveConfig(), resolving the Claude Code project
directory from the configured work_dir via the new resolveClaudeProjectDir helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(observe): add config.toml support for observe settings
Add ObserveConfig struct to ProjectConfig so users can configure terminal
session observation via [projects.observe] in config.toml, with CLI flags
(--observe, --observe-channel) taking precedence over config file values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(observe): rune-safe truncation, robust offset tracking, interface assertion
- Use file size for offset tracking instead of line-length calculation
to avoid drift from \r\n line endings or partial final lines
- Truncate messages at valid rune boundaries to prevent garbled UTF-8
- Add compile-time ObserverTarget interface assertion for Slack
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Leigh Stillard <leigh@stillard.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
In multi-workspace mode, ExecuteCronJob() for prompt (non-shell) jobs
always used the global e.agent and passed an empty workspaceDir to
processInteractiveMessageWith(). This caused the Claude Code agent to
start with the wrong working directory (often ~/.cc-connect) instead
of the workspace bound to the cron job's channel.
The fix mirrors handleMessage()'s workspace resolution logic:
1. Extract channelID from the cron job's sessionKey
2. Resolve the workspace binding via resolveWorkspace()
3. Get a workspace-specific agent via workspaceContext()
4. Pass the resolved workspace agent and dir to processInteractiveMessageWith()
job.WorkDir, when explicitly set, takes highest priority as an override.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When tool messages are hidden (quiet mode / display.tool_messages=false),
segmentStart stays 0 and the stream preview accumulates all text across
tool boundaries. However, EventResult used event.Content (last segment
only) to finalize the preview, overwriting the accumulated content.
- Prefer strings.Join(textParts, "") over event.Content when segmentStart
is 0, ensuring the final card contains the complete response
- Insert a visual line break between text segments at tool boundaries
when tool messages are hidden, so segments don't run together
Closes#549
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When thread_isolation is enabled, each group message gets a unique
session key (feishu:chat:root:{messageID}). This made /cron list
return empty results in group chat because the current message's
session key never matched any cron job's session key.
Fix: use ListByProject instead of ListBySessionKey so /cron list
shows all cron jobs for the current project regardless of which
session the command is sent from.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: persist per-workspace dir overrides across agent recreation
In multi-workspace mode, /dir overrides were lost after daemon restart
or workspace pool eviction because getOrCreateWorkspaceAgent always
recreated agents with work_dir=workspace root, ignoring the override
stored in project state.
Changes:
- Add WorkspaceDirOverrides map to ProjectStateStore for per-workspace
dir override persistence
- Apply persisted override when recreating workspace agents in
getOrCreateWorkspaceAgent
- Store workspace-specific overrides in dirApply when in multi-workspace
mode (set and reset)
- Auto-clear invalid overrides (missing/non-directory paths)
Backward compatible: existing work_dir_override field preserved for
single-workspace mode.
* fix: use per-channel dir overrides instead of per-workspace
Two channels bound to the same workspace were sharing /dir overrides.
Change the override key from workspace path to interactiveKey
(workspace:sessionKey) so each channel maintains independent dir state.
- Remove override from getOrCreateWorkspaceAgent (shared agent layer)
- Add applyChannelDirOverride helper, called in handleMessage and
commandContext (per-channel routing layer)
- dirApply now stores/clears overrides keyed by interactiveKey
* refactor: centralize multi-workspace context resolution
Use a shared workspaceContext helper so commandContext and message
routing build the same workspace agent/session/interactiveKey state in
one place. This keeps the per-channel dir override fix unchanged while
removing duplicate override application logic.
* fix: route multi-workspace dir overrides by effective work dir
Avoid mutating shared workspace agents when applying per-channel /dir
overrides. Resolve the effective work dir from project state and route
each channel to an agent context keyed by that dir instead.
This removes cross-channel work_dir races while preserving existing
/dir persistence and reset behavior.
* feat(references): normalize and render local agent references
* feat(show): add reference-aware file and directory viewing
* docs: document references config and show usage
* chore: remove local working docs from PR branch
* fix: address lint issues in reference pipeline
* fix: drop stale quiet leftovers after main rebase
* fix: clean remaining rebase markers in show tests
The /quiet slash command was a frequently used user-facing feature
that was accidentally removed when thinking/tool visibility was
split into separate config items. Restore it as a toggle that
flips both ThinkingMessages and ToolMessages together, persisting
the change via displaySaveFunc. Also restores /quiet in the help
card listing.
Made-with: Cursor